Compare commits
2 Commits
v0.4.6-dem
...
v0.4.8-dem
| Author | SHA1 | Date | |
|---|---|---|---|
| f88d441bbf | |||
| 4ac08e5a85 |
@@ -92,6 +92,12 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
if running := behavisionRunning(); running != "" {
|
||||
return fmt.Errorf("%s is running. Quit Behavision from the tray icon first, then run setup again.\n\n"+
|
||||
"Setting up underneath a running copy starts a second engine on the same port and, in a demo,\n"+
|
||||
"re-claims the shop while the open app still holds the old credentials.", running)
|
||||
}
|
||||
|
||||
py, ver, err := findPython()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -210,6 +216,22 @@ func engineSource() (string, error) {
|
||||
// `py -3` first on Windows: the launcher is what the official installer puts
|
||||
// on PATH, and `python` there is often the Microsoft Store stub that prints an
|
||||
// advert and exits 9009 instead of running anything.
|
||||
// behavisionRunning names a Behavision process if one is up. Windows only -
|
||||
// that is the platform setup ships on - and by image name via tasklist, which
|
||||
// needs no extra privilege.
|
||||
func behavisionRunning() string {
|
||||
if runtime.GOOS != "windows" {
|
||||
return ""
|
||||
}
|
||||
for _, name := range []string{"Behavision.exe", "behavision-agent.exe"} {
|
||||
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name, "/NH").Output()
|
||||
if err == nil && strings.Contains(strings.ToLower(string(out)), strings.ToLower(name)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findPython() (string, string, error) {
|
||||
type cand struct {
|
||||
exe string
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -218,13 +219,26 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
kill = k
|
||||
defer release()
|
||||
|
||||
// The last few lines the engine printed travel with the failure, because
|
||||
// "engine exited: exit status 1" sends somebody to a log file on a shop
|
||||
// PC, and the one line that matters - "port 8010 is already in use" - was
|
||||
// right there.
|
||||
var tailMu sync.Mutex
|
||||
var tail []string
|
||||
pumped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pumped)
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
fmt.Fprintln(s.opts.LogWriter, sc.Text())
|
||||
line := sc.Text()
|
||||
fmt.Fprintln(s.opts.LogWriter, line)
|
||||
tailMu.Lock()
|
||||
tail = append(tail, line)
|
||||
if len(tail) > 12 {
|
||||
tail = tail[1:]
|
||||
}
|
||||
tailMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -238,6 +252,12 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
if waitErr != nil {
|
||||
tailMu.Lock()
|
||||
reason := explain(tail)
|
||||
tailMu.Unlock()
|
||||
if reason != "" {
|
||||
return fmt.Errorf("%s (%v)", reason, waitErr)
|
||||
}
|
||||
return fmt.Errorf("engine exited: %w", waitErr)
|
||||
}
|
||||
return errors.New("engine exited unexpectedly with status 0")
|
||||
@@ -373,3 +393,28 @@ func LogFile(path string) (*os.File, error) {
|
||||
}
|
||||
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
}
|
||||
|
||||
// explain turns the engine's last output into the sentence the tray shows.
|
||||
// The cases are the ones seen on real installs; anything else shows the last
|
||||
// non-empty line verbatim.
|
||||
func explain(tail []string) string {
|
||||
last := ""
|
||||
for _, l := range tail {
|
||||
low := strings.ToLower(l)
|
||||
switch {
|
||||
case strings.Contains(low, "address already in use") || strings.Contains(low, "only one usage of each socket address"):
|
||||
return "port 8010 is already in use - another Behavision or its engine is still running"
|
||||
case strings.Contains(low, "no module named behavision"):
|
||||
return "the engine is not installed in this Python - run behavision-setup again"
|
||||
case strings.Contains(low, "modulenotfounderror") || strings.Contains(low, "importerror"):
|
||||
return "the engine is missing a library - run behavision-setup again"
|
||||
}
|
||||
if strings.TrimSpace(l) != "" {
|
||||
last = strings.TrimSpace(l)
|
||||
}
|
||||
}
|
||||
if len(last) > 120 {
|
||||
last = last[:120] + "…"
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
})
|
||||
|
||||
a.startPipeline(ctx)
|
||||
go a.watchConfig(ctx)
|
||||
|
||||
// Recognition starts with the app. Until this, the engine only ever
|
||||
// started when somebody pressed Start - which meant a till that rebooted
|
||||
@@ -467,6 +468,62 @@ func (a *App) Claim(code string) (SessionInfo, error) {
|
||||
// the current config. Only Claim needs it today; it exists as its own method
|
||||
// because "stop everything that reads the config, then start it" is the part
|
||||
// that is easy to get half right.
|
||||
// watchConfig reloads agent.json when something else writes it.
|
||||
//
|
||||
// behavision-setup re-run on a PC with the app open re-claims the shop and
|
||||
// rotates its API token; the running app kept the old one and every camera
|
||||
// sync was refused from then on - heartbeats still flowed, so head office
|
||||
// looked fine while the cameras went stale. A claim from `behavision-agent
|
||||
// claim` does the same. Rather than ask people to restart the app, the app
|
||||
// watches the file and picks the new credentials up itself.
|
||||
func (a *App) watchConfig(ctx context.Context) {
|
||||
path := agentpaths.AgentConfig()
|
||||
last := mtime(path)
|
||||
t := time.NewTicker(10 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
now := mtime(path)
|
||||
if now.IsZero() || now.Equal(last) {
|
||||
continue
|
||||
}
|
||||
last = now
|
||||
fresh, err := agentcfg.Load(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fresh = fresh.WithEngineCredentials(agentpaths.APICredentials())
|
||||
a.mu.Lock()
|
||||
changed := fresh.AgentToken != a.cfg.AgentToken || fresh.SiteID != a.cfg.SiteID ||
|
||||
fresh.BrokerPassword != a.cfg.BrokerPassword || fresh.CloudBase != a.cfg.CloudBase ||
|
||||
fresh.Standalone != a.cfg.Standalone
|
||||
if changed {
|
||||
// Keep this process's live session; a claim clears it in the file
|
||||
// deliberately, and that is honoured too.
|
||||
a.cfg = fresh
|
||||
if fresh.SessionToken == "" {
|
||||
a.cloud.Clear()
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if changed {
|
||||
a.restartPipeline()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mtime(path string) time.Time {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return st.ModTime()
|
||||
}
|
||||
|
||||
func (a *App) restartPipeline() {
|
||||
if a.stopBridge != nil {
|
||||
a.stopBridge()
|
||||
|
||||
@@ -101,9 +101,11 @@ func (t *tray) onReady(ctx context.Context) {
|
||||
case <-t.mOpen.ClickedCh:
|
||||
runtime.Show(ctx)
|
||||
case <-t.mStart.ClickedCh:
|
||||
t.app.StartEngine()
|
||||
// Never on the menu loop itself: a stop waits for the process to
|
||||
// exit, and a menu that is deaf for the duration looks broken.
|
||||
go func() { t.app.StartEngine(); t.refresh() }()
|
||||
case <-t.mStop.ClickedCh:
|
||||
t.app.StopEngine()
|
||||
go func() { t.app.StopEngine(); t.refresh() }()
|
||||
case <-t.mLogs.ClickedCh:
|
||||
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
|
||||
case <-t.mQuit.ClickedCh:
|
||||
@@ -129,23 +131,29 @@ func (t *tray) poll(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
s := t.app.EngineStatus()
|
||||
state, label := describe(s)
|
||||
systray.SetIcon(iconFor(state))
|
||||
systray.SetTooltip("Behavision — " + label)
|
||||
if t.mStatus != nil {
|
||||
t.mStatus.SetTitle(label)
|
||||
}
|
||||
running := s.State == "running"
|
||||
if t.mStart != nil && t.mStop != nil {
|
||||
if running {
|
||||
t.mStart.Disable()
|
||||
t.mStop.Enable()
|
||||
} else {
|
||||
t.mStart.Enable()
|
||||
t.mStop.Disable()
|
||||
}
|
||||
}
|
||||
t.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refresh redraws the icon and the menu from EngineStatus - the same source
|
||||
// the window reads, so the two cannot disagree.
|
||||
func (t *tray) refresh() {
|
||||
s := t.app.EngineStatus()
|
||||
state, label := describe(s)
|
||||
systray.SetIcon(iconFor(state))
|
||||
systray.SetTooltip("Behavision — " + label)
|
||||
if t.mStatus != nil {
|
||||
t.mStatus.SetTitle(label)
|
||||
}
|
||||
running := s.State == "running" || s.State == "starting" || s.State == "backoff"
|
||||
if t.mStart != nil && t.mStop != nil {
|
||||
if running {
|
||||
t.mStart.Disable()
|
||||
t.mStop.Enable()
|
||||
} else {
|
||||
t.mStart.Enable()
|
||||
t.mStop.Disable()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +168,9 @@ func describe(s EngineStatus) (state, label string) {
|
||||
case s.State == "stopped":
|
||||
return "stopped", "Stopped"
|
||||
case s.State == "failed":
|
||||
return "error", "Failed — " + firstLine(s.Error)
|
||||
// The supervisor's error is already a sentence (port in use, missing
|
||||
// library); show it whole, because it is the thing to act on.
|
||||
return "error", "Not running — " + firstLine(s.Error)
|
||||
case s.State == "backoff":
|
||||
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
|
||||
case !s.Reachable:
|
||||
|
||||
@@ -159,6 +159,9 @@ type Store interface {
|
||||
// one opened by mistake - and returns its broker username. A shop with
|
||||
// history is closed, not deleted.
|
||||
DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error)
|
||||
// UpdateSite changes what a person reads - the name, the timezone. Never
|
||||
// the slug: the shop PC and the broker ACL are keyed on it.
|
||||
UpdateSite(ctx context.Context, clientID, siteID string, in SiteUpdate) (SiteHealth, error)
|
||||
|
||||
// --- enrolment ---
|
||||
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
|
||||
@@ -301,6 +304,7 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
|
||||
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
|
||||
mux.HandleFunc("DELETE /api/sites/{site}", s.authed(s.handleDeleteSite))
|
||||
mux.HandleFunc("PATCH /api/sites/{site}", s.authed(s.handleUpdateSite))
|
||||
|
||||
// Cameras, onboarded from head office. The shop PC still does the
|
||||
// connecting - it is the only thing on the camera's network - so these
|
||||
|
||||
@@ -554,6 +554,23 @@ func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow,
|
||||
return ClientRow{}, nil, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateSite(_ context.Context, _ string, siteID string, in SiteUpdate) (SiteHealth, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := range f.sites {
|
||||
if f.sites[i].SiteID == siteID {
|
||||
if in.Name != nil {
|
||||
f.sites[i].Name = *in.Name
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
f.sites[i].Timezone = *in.Timezone
|
||||
}
|
||||
return f.sites[i], nil
|
||||
}
|
||||
}
|
||||
return SiteHealth{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
@@ -257,3 +258,57 @@ func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) {
|
||||
// ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything
|
||||
// under it.
|
||||
var ErrSiteInUse = errors.New("site has cameras or visits")
|
||||
|
||||
// PATCH /api/sites/{site} - rename a shop or change its timezone. Owner or
|
||||
// manager. The slug is not in the body and would be refused by the database
|
||||
// if it were: it is what the shop PC calls itself and a segment of the broker
|
||||
// topic, and renaming it would orphan both.
|
||||
func (s *Server) handleUpdateSite(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden", "Only a manager or the owner can change a shop.")
|
||||
return
|
||||
}
|
||||
site, ok := s.resolveSite(w, r, r.PathValue("site"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in SiteUpdate
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Name != nil {
|
||||
n := clip(trim(*in.Name), 120)
|
||||
if n == "" {
|
||||
badRequest(w, "The shop needs a name.")
|
||||
return
|
||||
}
|
||||
in.Name = &n
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
if _, err := time.LoadLocation(strings.TrimSpace(*in.Timezone)); err != nil {
|
||||
badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Name == nil && in.Timezone == nil {
|
||||
badRequest(w, "Nothing to change: give a name or a timezone.")
|
||||
return
|
||||
}
|
||||
out, err := s.Store.UpdateSite(r.Context(), p.ClientID, site, in)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "update site", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "site.updated", Entity: "site", EntityID: site,
|
||||
Detail: map[string]any{"name": out.Name, "timezone": out.Timezone},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -108,3 +108,22 @@ func TestNoBrokerConfiguredSaysSo(t *testing.T) {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManagerRenamesAShopButTheSlugStays(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedSite(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"name": "TeNext Coimbatore"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out SiteHealth
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.Name != "TeNext Coimbatore" || out.Slug != "chennai" {
|
||||
t.Fatalf("renamed wrong: %+v", out)
|
||||
}
|
||||
if rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"timezone": "Mars/Olympus"}); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad timezone accepted: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,13 @@ type NewSite struct {
|
||||
Password string `json:"-"`
|
||||
}
|
||||
|
||||
// SiteUpdate is the editable part of a shop. Both optional; an absent field
|
||||
// is left alone.
|
||||
type SiteUpdate struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type SiteHealth struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
|
||||
@@ -146,3 +146,15 @@ func (s *Store) DeleteEmptySite(ctx context.Context, clientID, siteID string) (s
|
||||
}
|
||||
return username, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSite(ctx context.Context, clientID, siteID string, in api.SiteUpdate) (api.SiteHealth, error) {
|
||||
var out api.SiteHealth
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
UPDATE sites
|
||||
SET name = COALESCE($3, name),
|
||||
timezone = COALESCE($4, timezone)
|
||||
WHERE id = $1::uuid AND client_id = $2::uuid
|
||||
RETURNING id::text, slug, name, timezone`, siteID, clientID, in.Name, in.Timezone).
|
||||
Scan(&out.SiteID, &out.Slug, &out.Name, &out.Timezone)
|
||||
return out, err
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
server/internal/web/dist/index.html
vendored
4
server/internal/web/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-CAACw-qR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-KC4SOUb9.css">
|
||||
<script type="module" crossorigin src="/assets/index-Ckr5hGZd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -206,6 +206,8 @@ export const api = {
|
||||
|
||||
sites: () => send('GET', '/api/sites'),
|
||||
createSite: (input) => send('POST', '/api/sites', input),
|
||||
updateSite: (site, input) => send('PATCH', `/api/sites/${encodeURIComponent(site)}`, input),
|
||||
deleteSite: (site) => send('DELETE', `/api/sites/${encodeURIComponent(site)}`),
|
||||
|
||||
// The live arrivals feed. `cursor` is opaque and must be echoed back.
|
||||
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
|
||||
|
||||
@@ -568,3 +568,7 @@ button.ghost.danger:hover { border-color: var(--bad); }
|
||||
letter-spacing: .04em; text-transform: uppercase; }
|
||||
|
||||
.ask-btn .mark { width: 18px; height: 18px; }
|
||||
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
button.ghost.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
|
||||
input[readonly] { opacity: .6; }
|
||||
|
||||
@@ -8,7 +8,7 @@ import { api } from '../api.js'
|
||||
// recognising almost nobody. Every step names what to do when it fails, and the
|
||||
// shop is only "working" when all of them pass: a partial pass is not a working
|
||||
// shop, and calling it one is how that site got signed off.
|
||||
export default function SiteCheck({ site, onClose }) {
|
||||
export default function SiteCheck({ site, user, onClose, onChanged }) {
|
||||
const [result, setResult] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -89,6 +89,7 @@ export default function SiteCheck({ site, onClose }) {
|
||||
)}
|
||||
|
||||
<ClaimPC site={site} />
|
||||
<ShopSettings site={site} user={user} onChanged={onChanged} onClose={onClose} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -169,3 +170,74 @@ function expiry(iso) {
|
||||
if (days <= 0) return 'today'
|
||||
return days === 1 ? 'tomorrow' : `in ${days} days`
|
||||
}
|
||||
|
||||
// The shop's own details: rename, timezone, and - for a shop opened by mistake
|
||||
// - removal. The short name is shown but not editable: it is what the shop PC
|
||||
// calls itself and a segment of the broker topic, so renaming it would orphan
|
||||
// both. The display name is what people read, and a system that cannot fix a
|
||||
// typo in a shop's name has confused the two.
|
||||
function ShopSettings({ site, user, onChanged, onClose }) {
|
||||
const [name, setName] = useState(site.name)
|
||||
const [tz, setTz] = useState(site.timezone || 'Asia/Kolkata')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const canManage = user?.role === 'owner' || user?.role === 'manager'
|
||||
const isOwner = user?.role === 'owner'
|
||||
if (!canManage) return null
|
||||
const dirty = name.trim() !== site.name || tz.trim() !== (site.timezone || 'Asia/Kolkata')
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.updateSite(site.slug || site.site_id, { name: name.trim(), timezone: tz.trim() })
|
||||
onChanged?.()
|
||||
} catch (err) { setError(err.message) } finally { setBusy(false) }
|
||||
}
|
||||
const remove = async () => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.deleteSite(site.slug || site.site_id)
|
||||
onChanged?.(); onClose?.()
|
||||
} catch (err) { setError(err.message); setConfirming(false) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="claim">
|
||||
<h3>Shop details</h3>
|
||||
<form onSubmit={save}>
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input value={name} onChange={e => setName(e.target.value)} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Short name</span>
|
||||
<input value={site.slug} readOnly />
|
||||
<span className="hint">Fixed: the shop PC and the broker are keyed on it.</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Timezone</span>
|
||||
<input value={tz} onChange={e => setTz(e.target.value)} />
|
||||
</label>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<div className="row">
|
||||
<button className="primary" disabled={busy || !dirty}>{busy ? 'Saving…' : 'Save'}</button>
|
||||
{isOwner && !confirming && (
|
||||
<button type="button" className="ghost danger" onClick={() => setConfirming(true)}>Remove this shop…</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
{confirming && (
|
||||
<div className="banner warn" style={{ marginTop: 12 }}>
|
||||
<b>Remove {site.name}?</b>
|
||||
<p className="sub">Only possible while it has no cameras and no visits. A shop with history is kept.</p>
|
||||
<div className="row">
|
||||
<button className="primary" disabled={busy} onClick={remove}>{busy ? 'Removing…' : 'Yes, remove it'}</button>
|
||||
<button className="ghost" onClick={() => setConfirming(false)}>Keep it</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function Sites({ user }) {
|
||||
{/* Reachable per shop, at last. The smoke test used to hang off a single
|
||||
button on the camera screen that always checked sites[0], so with two
|
||||
shops the second could not be checked at all. */}
|
||||
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
|
||||
{checking && <SiteCheck site={checking} user={user} onClose={() => setChecking(null)} onChanged={reload} />}
|
||||
{opening && <NewShop onClose={() => setOpening(false)}
|
||||
onCreated={() => { setOpening(false); reload() }} />}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user