A fresh shop PC could never authenticate to its own engine
The agent read the engine's generated credential file once, at startup. On a brand new install that file does not exist yet: the agent starts the engine, and the engine writes its credential seconds later. So the agent held an empty credential for the life of the process and every call it makes - health, stats, camera sync, the embedding for a visit - came back 401, with a tray showing a red engine that was running perfectly. Measured on a fresh state directory today: three 401s, no camera ever reconciled, and the engine left running the YAML-seeded main stream instead of the sub-stream head office holds. The install script hid this on Windows because setup runs the engine once before the app starts. config.Creds resolves lazily and re-reads on a rejection; the camera client, the supervisor and the desktop app's engine client all retry once when it changes. A configured BEHAVISION_API_USER is never re-read - an operator who set one means it. Tests pin the actual first-run ordering. Also adds demo/, a one-screen live console for showing the whole chain: camera, the six steps with a measured camera-to-cloud latency, the customer editable in place, and the raw JSON a phone and a dashboard receive from production side by side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -66,3 +66,4 @@ node_modules/
|
||||
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
|
||||
/behavision.egg-info/
|
||||
/.prod/
|
||||
/.demo/
|
||||
|
||||
@@ -161,6 +161,7 @@ func cmdStatus() error {
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -169,7 +170,7 @@ func cmdStatus() error {
|
||||
Command: func(context.Context) *exec.Cmd { return nil },
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -204,6 +205,7 @@ func cmdRun() error {
|
||||
// which is the default. Reading it here is what stops every call the agent
|
||||
// makes to the engine coming back 401 on a stock install.
|
||||
cfg = cfg.WithEngineCredentials(paths.APICredentials())
|
||||
creds := config.NewCreds(paths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
// Opened before the engine starts: detections arriving in the first second
|
||||
// must have somewhere to land.
|
||||
q, err := spool.Open(paths.SpoolDir(), cfg.SpoolMax)
|
||||
@@ -244,7 +246,7 @@ func cmdRun() error {
|
||||
LogWriter: logFile,
|
||||
HealthURL: strings.TrimRight(cfg.APIBase, "/") + "/api/health",
|
||||
StatsURL: strings.TrimRight(cfg.APIBase, "/") + "/api/stats",
|
||||
User: cfg.APIUser, Password: cfg.APIPassword,
|
||||
User: cfg.APIUser, Password: cfg.APIPassword, Creds: creds,
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(),
|
||||
@@ -279,6 +281,7 @@ func cmdRun() error {
|
||||
cloud := cameras.NewCloudClient(cfg.CloudBase, cfg.AgentToken)
|
||||
cloud.Upload = uploader.UploadBytes
|
||||
eng := cameras.NewEngineClient(cfg.APIBase, cfg.APIUser, cfg.APIPassword)
|
||||
eng.Creds = creds
|
||||
go cameras.New(eng, cloud, logger).Run(ctx)
|
||||
// The live relay, which uploads nothing until somebody at head office is
|
||||
// actually watching a camera.
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/bridge"
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
)
|
||||
|
||||
// EngineClient talks to the recognition engine on this PC's loopback.
|
||||
@@ -21,7 +22,12 @@ type EngineClient struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
Client *http.Client
|
||||
// Creds re-reads the engine's generated credential when one is rejected.
|
||||
// Without it a fresh install is 401 for the life of the process: the agent
|
||||
// starts the engine, and the engine writes its credential file seconds
|
||||
// after the agent has already read (and failed to find) it.
|
||||
Creds *config.Creds
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewEngineClient(base, user, password string) *EngineClient {
|
||||
@@ -50,14 +56,24 @@ func (e *EngineClient) do(ctx context.Context, method, path string, body, out an
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if e.User != "" {
|
||||
req.SetBasicAuth(e.User, e.Password)
|
||||
user, pass := e.User, e.Password
|
||||
if e.Creds != nil {
|
||||
user, pass = e.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := e.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized && e.Creds != nil && e.Creds.Refresh() {
|
||||
// The engine generated its credential after we last looked. Read it
|
||||
// and try once more rather than failing for the life of the process.
|
||||
resp.Body.Close()
|
||||
return e.do(ctx, method, path, body, out)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The engine's message, not just a status. "camera stored but failed to
|
||||
// start: connection refused" is something an operator can act on;
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// EngineCredentials reads the Basic credentials the engine generated for
|
||||
@@ -60,3 +61,60 @@ func (c Config) WithEngineCredentials(path string) Config {
|
||||
c.APIUser, c.APIPassword = EngineCredentials(path)
|
||||
return c
|
||||
}
|
||||
|
||||
// Creds resolves the engine's Basic credentials, re-reading the file when it
|
||||
// has none.
|
||||
//
|
||||
// Reading once at startup is wrong on a fresh install, and that is the case
|
||||
// that matters: the agent starts the engine, the engine generates its
|
||||
// credential and writes the file a few seconds later, and an agent that read
|
||||
// the file before that holds "" forever. Every call it makes - health, stats,
|
||||
// camera sync, the embedding for a visit - then comes back 401 for the life of
|
||||
// the process, on a brand new shop PC, with the tray showing a red engine that
|
||||
// is running perfectly. Measured on a fresh state directory: three 401s and no
|
||||
// camera ever reconciled.
|
||||
//
|
||||
// A configured credential is never re-read: an operator who set
|
||||
// BEHAVISION_API_USER means it.
|
||||
type Creds struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
user string
|
||||
pass string
|
||||
fixed bool
|
||||
}
|
||||
|
||||
// NewCreds takes whatever the config already has. Non-empty means configured,
|
||||
// and is used unchanged.
|
||||
func NewCreds(path, user, password string) *Creds {
|
||||
c := &Creds{path: path, user: user, pass: password}
|
||||
c.fixed = user != "" || password != ""
|
||||
return c
|
||||
}
|
||||
|
||||
// Get returns the current pair, reading the file if it has nothing yet.
|
||||
func (c *Creds) Get() (string, string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.user == "" && !c.fixed {
|
||||
c.user, c.pass = EngineCredentials(c.path)
|
||||
}
|
||||
return c.user, c.pass
|
||||
}
|
||||
|
||||
// Refresh re-reads the file after a rejection and reports whether the pair
|
||||
// changed. Callers retry once when it did - which covers both the fresh-install
|
||||
// race and a credential the engine regenerated under a running agent.
|
||||
func (c *Creds) Refresh() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.fixed {
|
||||
return false
|
||||
}
|
||||
u, p := EngineCredentials(c.path)
|
||||
if u == c.user && p == c.pass {
|
||||
return false
|
||||
}
|
||||
c.user, c.pass = u, p
|
||||
return u != ""
|
||||
}
|
||||
|
||||
77
agent/pkg/config/credentials_firstrun_test.go
Normal file
77
agent/pkg/config/credentials_firstrun_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The sequence on a brand new shop PC, in order:
|
||||
//
|
||||
// agent starts -> file does not exist yet
|
||||
// agent starts the engine
|
||||
// engine generates its credential and writes the file
|
||||
// agent calls the engine -> must now succeed
|
||||
//
|
||||
// Read once at startup, the agent holds "" for the life of the process and
|
||||
// every engine call is 401: health, stats, camera sync, the embedding for a
|
||||
// visit. The tray shows a red engine that is running perfectly, and nothing
|
||||
// says why. Measured on a fresh state directory before this existed.
|
||||
func TestCredentialsArriveAfterTheAgentHasAlreadyLooked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
|
||||
creds := NewCreds(path, "", "") // nothing configured, file not there yet
|
||||
if u, _ := creds.Get(); u != "" {
|
||||
t.Fatalf("expected no credential before the engine has written one, got %q", u)
|
||||
}
|
||||
|
||||
// the engine starts and writes its credential
|
||||
if err := os.WriteFile(path, []byte("username=behavision\npassword=s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// a 401 makes the agent look again
|
||||
if !creds.Refresh() {
|
||||
t.Fatal("Refresh did not pick up the credential the engine just wrote")
|
||||
}
|
||||
u, p := creds.Get()
|
||||
if u != "behavision" || p != "s3cret" {
|
||||
t.Fatalf("got %q/%q", u, p)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator who set BEHAVISION_API_USER means it, and a file must never
|
||||
// override them.
|
||||
func TestAConfiguredCredentialIsNeverReplacedByTheFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
if err := os.WriteFile(path, []byte("username=generated\npassword=nope\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creds := NewCreds(path, "chosen", "byhand")
|
||||
if u, p := creds.Get(); u != "chosen" || p != "byhand" {
|
||||
t.Fatalf("configured credential was replaced: %q/%q", u, p)
|
||||
}
|
||||
if creds.Refresh() {
|
||||
t.Fatal("Refresh overrode a configured credential")
|
||||
}
|
||||
}
|
||||
|
||||
// A credential the engine regenerates under a running agent is picked up too -
|
||||
// the same mechanism, and the reason paths.APICredentials says the agent reads
|
||||
// the file "rather than storing a second copy".
|
||||
func TestARegeneratedCredentialIsPickedUp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "api_credentials.txt")
|
||||
os.WriteFile(path, []byte("username=behavision\npassword=old\n"), 0o600)
|
||||
creds := NewCreds(path, "", "")
|
||||
creds.Get()
|
||||
os.WriteFile(path, []byte("username=behavision\npassword=new\n"), 0o600)
|
||||
if !creds.Refresh() {
|
||||
t.Fatal("a regenerated password was not picked up")
|
||||
}
|
||||
if _, p := creds.Get(); p != "new" {
|
||||
t.Fatalf("still holding %q", p)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -59,6 +61,9 @@ type Options struct {
|
||||
// no captured output is undiagnosable, which on a customer site means a
|
||||
// site visit.
|
||||
LogWriter io.Writer
|
||||
// Creds re-reads the engine's generated credential when one is rejected,
|
||||
// which is the ordinary case on a first run.
|
||||
Creds *config.Creds
|
||||
// HealthURL, StatsURL, User, Password address the engine's own API.
|
||||
HealthURL string
|
||||
StatsURL string
|
||||
@@ -409,14 +414,24 @@ func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.opts.User != "" {
|
||||
req.SetBasicAuth(s.opts.User, s.opts.Password)
|
||||
user, pass := s.opts.User, s.opts.Password
|
||||
if s.opts.Creds != nil {
|
||||
user, pass = s.opts.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized && s.opts.Creds != nil && s.opts.Creds.Refresh() {
|
||||
// See cameras.EngineClient: on a fresh install the engine writes its
|
||||
// credential after the agent has already read for one.
|
||||
resp.Body.Close()
|
||||
return s.getJSON(ctx, url, out)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s returned %s", url, resp.Status)
|
||||
}
|
||||
|
||||
251
demo/console.html
Normal file
251
demo/console.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Behavision — live demo</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0A0E12; --s1:#11171C; --s2:#161D24; --s3:#1D262E;
|
||||
--line:#24303A; --line2:#1B242C;
|
||||
--ink:#E8EEF3; --ink2:#9FB0BD; --ink3:#6B7E8C;
|
||||
--accent:#3DD0C4; --accent-dim:#123039;
|
||||
--ok:#3FBF7F; --warn:#E0A33A; --bad:#E15B4C;
|
||||
--mono:'SF Mono',ui-monospace,Menlo,monospace;
|
||||
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--ink);font-family:var(--font);font-size:14px;line-height:1.5;
|
||||
-webkit-font-smoothing:antialiased;padding:18px;min-height:100vh}
|
||||
.top{display:flex;align-items:center;gap:14px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.brand{display:flex;align-items:center;gap:10px;margin-right:auto}
|
||||
.brand img{width:26px;height:26px;object-fit:contain}
|
||||
.brand b{font-size:16px;letter-spacing:-.01em}
|
||||
.brand span{color:var(--ink3);font-size:12px}
|
||||
.pill{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:99px;
|
||||
border:1px solid var(--line);background:var(--s1);font-size:12px;color:var(--ink2)}
|
||||
.dot{width:7px;height:7px;border-radius:99px;background:var(--ink3);flex:none}
|
||||
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,191,127,.16)}
|
||||
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px rgba(225,91,76,.16)}
|
||||
.dot.warn{background:var(--warn);box-shadow:0 0 0 3px rgba(224,163,58,.16)}
|
||||
|
||||
.grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:14px;align-items:start}
|
||||
@media(max-width:1100px){.grid{grid-template-columns:minmax(0,1fr)}}
|
||||
.card{background:var(--s1);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
||||
.card h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink3);
|
||||
padding:12px 16px;border-bottom:1px solid var(--line2);display:flex;align-items:center;gap:10px}
|
||||
.card h2 .grow{margin-left:auto;font-weight:500;letter-spacing:0;text-transform:none;font-size:12px;color:var(--ink3)}
|
||||
.pad{padding:16px}
|
||||
|
||||
.cam{position:relative;aspect-ratio:16/9;background:#05090C}
|
||||
.cam img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.cam .none{position:absolute;inset:0;display:grid;place-items:center;color:var(--ink3);font-size:13px;text-align:center;padding:20px}
|
||||
.cam .tag{position:absolute;top:10px;left:10px;background:rgba(10,14,18,.78);backdrop-filter:blur(8px);
|
||||
border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:11.5px;font-family:var(--mono)}
|
||||
|
||||
/* the chain */
|
||||
.chain{display:flex;flex-direction:column;gap:0}
|
||||
.step{display:grid;grid-template-columns:26px 1fr auto;gap:12px;align-items:start;padding:11px 16px;
|
||||
border-bottom:1px solid var(--line2);opacity:.38;transition:opacity .25s}
|
||||
.step:last-child{border-bottom:0}
|
||||
.step.on{opacity:1}
|
||||
.step .n{width:22px;height:22px;border-radius:99px;display:grid;place-items:center;font-size:11px;font-weight:600;
|
||||
background:var(--s3);color:var(--ink3);border:1px solid var(--line);margin-top:1px}
|
||||
.step.on .n{background:var(--accent);color:#04161B;border-color:transparent}
|
||||
.step b{font-size:13.5px;font-weight:550;display:block}
|
||||
.step small{color:var(--ink2);font-size:12px;display:block;margin-top:1px;font-family:var(--mono)}
|
||||
.step .ms{font-family:var(--mono);font-size:11.5px;color:var(--accent);white-space:nowrap;margin-top:2px}
|
||||
|
||||
.empty{padding:34px 16px;text-align:center;color:var(--ink3);font-size:13px;line-height:1.6}
|
||||
.empty b{display:block;color:var(--ink2);font-size:14px;margin-bottom:5px}
|
||||
|
||||
/* customer */
|
||||
.who{display:flex;gap:13px;align-items:center;padding:16px;border-bottom:1px solid var(--line2)}
|
||||
.av{width:50px;height:50px;border-radius:10px;background:var(--s3);border:1px solid var(--line);
|
||||
display:grid;place-items:center;font-weight:600;font-size:17px;color:var(--ink2);flex:none;overflow:hidden}
|
||||
.av img{width:100%;height:100%;object-fit:cover}
|
||||
.who .n{font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||||
.who .m{color:var(--ink3);font-size:12.5px;margin-top:2px}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10.5px;font-weight:600;
|
||||
letter-spacing:.04em;text-transform:uppercase}
|
||||
.badge.new{background:var(--accent-dim);color:var(--accent)}
|
||||
.badge.seen{background:rgba(63,191,127,.14);color:var(--ok)}
|
||||
label{display:block;font-size:11.5px;font-weight:550;color:var(--ink2);margin-bottom:5px}
|
||||
input{width:100%;background:var(--s2);border:1px solid var(--line);border-radius:7px;padding:9px 11px;
|
||||
color:var(--ink);font:inherit;font-size:13.5px}
|
||||
input:focus{outline:none;border-color:var(--accent)}
|
||||
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
|
||||
button{background:var(--accent);color:#04161B;border:0;border-radius:7px;padding:9px 16px;
|
||||
font:inherit;font-size:13px;font-weight:600;cursor:pointer}
|
||||
button:disabled{opacity:.45;cursor:default}
|
||||
button.sec{background:var(--s3);color:var(--ink);border:1px solid var(--line)}
|
||||
.saved{color:var(--ok);font-size:12.5px;margin-top:9px;display:flex;align-items:center;gap:6px}
|
||||
|
||||
/* raw json */
|
||||
pre{font-family:var(--mono);font-size:11px;line-height:1.55;color:var(--ink2);
|
||||
background:#080C10;border-top:1px solid var(--line2);padding:13px 16px;margin:0;
|
||||
max-height:230px;overflow:auto;white-space:pre-wrap;word-break:break-word}
|
||||
.req{font-family:var(--mono);font-size:11.5px;color:var(--accent);padding:10px 16px;background:var(--s2)}
|
||||
.req .st{float:right;color:var(--ink3)}
|
||||
.hint{color:var(--ink3);font-size:12px;padding:10px 16px 14px;line-height:1.55}
|
||||
.stack{display:flex;flex-direction:column;gap:14px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div class="brand">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDIxcy04LTQuNS04LTEwYTQuNSA0LjUgMCAwIDEgOC0yLjggNC41IDQuNSAwIDAgMSA4IDIuOGMwIDUuNS04IDEwLTggMTB6IiBmaWxsPSIjRjJDMTFGIi8+PC9zdmc+" alt="">
|
||||
<div><b>Behavision</b> <span id="site">— live demo</span></div>
|
||||
</div>
|
||||
<span class="pill"><i class="dot" id="d-eng"></i><span id="t-eng">engine…</span></span>
|
||||
<span class="pill"><i class="dot" id="d-cam"></i><span id="t-cam">camera…</span></span>
|
||||
<span class="pill"><i class="dot" id="d-cloud"></i><span id="t-cloud">cloud…</span></span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>The camera <span class="grow" id="camname"></span></h2>
|
||||
<div class="cam">
|
||||
<img id="feed" alt="" style="display:none">
|
||||
<div class="none" id="feednone">waiting for the camera…</div>
|
||||
<div class="tag" id="camtag" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>The customer <span class="grow">type a name, then walk past again</span></h2>
|
||||
<div id="cust">
|
||||
<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h2>What just happened <span class="grow" id="lat"></span></h2>
|
||||
<div id="chain"><div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>What the mobile app receives</h2>
|
||||
<div class="req" id="m-req">GET /api/visits<span class="st" id="m-st"></span></div>
|
||||
<pre id="m-body">…</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>What the dashboard receives</h2>
|
||||
<div class="req" id="d-req">GET /api/reports/footfall<span class="st" id="d-st"></span></div>
|
||||
<pre id="d-body">…</pre>
|
||||
<div class="hint">Both of these are the real production API at mcp.loyaly.ai, called from this
|
||||
machine with a staff login — not a mock, and not the local engine.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
let camStarted = null, current = null, savedFor = null;
|
||||
|
||||
function setPill(dot, text, tone, label){
|
||||
$(dot).className = 'dot' + (tone ? ' ' + tone : '');
|
||||
$(text).textContent = label;
|
||||
}
|
||||
|
||||
function initials(name, ref){
|
||||
const m = /^Visitor (\d+)$/.exec((name||'').trim());
|
||||
if (m) return m[1];
|
||||
const w = (name||'').trim().split(/\s+/).filter(Boolean);
|
||||
if (!w.length) return '?';
|
||||
return (w[0][0] + (w[1]?.[0] ?? '')).toUpperCase();
|
||||
}
|
||||
|
||||
function drawChain(e){
|
||||
if (!e){ $('#chain').innerHTML = '<div class="empty"><b>Waiting</b>Every step below lights up as it really happens.</div>'; $('#lat').textContent=''; return; }
|
||||
const g = e.engine, c = e.cloud;
|
||||
const steps = [
|
||||
[true, 'Camera saw a face', g.quality != null ? `quality ${(+g.quality).toFixed(2)} · camera ${g.camera}` : `camera ${g.camera}`, ''],
|
||||
[true, e.kind === 'new' ? 'Engine: nobody it knows → enrolled' : 'Engine: matched a returning customer',
|
||||
(g.label || '') + (g.similarity != null && g.similarity >= 0 ? ` · similarity ${(+g.similarity).toFixed(2)}` : '') , ''],
|
||||
[true, 'Agent queued the visit', 'durable on this disk until the broker confirms', ''],
|
||||
[!!c, 'Broker delivered it', 'MQTT over TLS to mcp.loyaly.ai', ''],
|
||||
[!!c, 'Server recorded it', c ? `${c.site} · ${c.is_new ? 'new customer' : 'returning'}` : 'waiting…', ''],
|
||||
[!!c, 'Mobile + dashboard can see it', c ? `visit ${String(c.visit_id).slice(0,8)}` : 'waiting…',
|
||||
e.latency != null ? `+${e.latency}s` : ''],
|
||||
];
|
||||
$('#chain').innerHTML = steps.map(([on,title,sub,ms],i)=>
|
||||
`<div class="step ${on?'on':''}"><div class="n">${i+1}</div><div><b>${title}</b><small>${sub}</small></div><div class="ms">${ms}</div></div>`
|
||||
).join('');
|
||||
$('#lat').textContent = e.latency != null ? `camera → cloud in ${e.latency}s` : '';
|
||||
}
|
||||
|
||||
function drawCustomer(e){
|
||||
const c = e && e.cloud;
|
||||
if (!c){ if(!current) $('#cust').innerHTML = '<div class="empty"><b>Nobody yet</b>Walk in front of the camera.</div>'; return; }
|
||||
const changed = !current || current.visitor_id !== c.visitor_id || current.visit_id !== c.visit_id;
|
||||
if (!changed) return;
|
||||
current = c;
|
||||
const name = c.label || 'Unrecognised';
|
||||
const img = c.image && c.image.available && c.image.url;
|
||||
$('#cust').innerHTML = `
|
||||
<div class="who">
|
||||
<div class="av">${img ? `<img src="${img}">` : initials(name)}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="n">${name}</div>
|
||||
<div class="m">${c.ref ? c.ref + ' · ' : ''}${c.is_new ? 'first time here' : 'returning'}${c.similarity>0 ? ' · match ' + (+c.similarity).toFixed(2) : ''}</div>
|
||||
</div>
|
||||
<span class="badge ${c.is_new?'new':'seen'}">${c.is_new?'new':'returning'}</span>
|
||||
</div>
|
||||
<div class="pad">
|
||||
<div class="row">
|
||||
<div><label>Name</label><input id="f-name" placeholder="e.g. Suriya" value=""></div>
|
||||
<div><label>Phone</label><input id="f-phone" placeholder="+91…" value=""></div>
|
||||
</div>
|
||||
<button id="save">Save to the customer record</button>
|
||||
<div id="savedmsg"></div>
|
||||
<div class="hint" style="padding:12px 0 0">This writes to the production API. Walk past again and
|
||||
the name comes back through the cloud instead of “${name}”.</div>
|
||||
</div>`;
|
||||
$('#save').onclick = async () => {
|
||||
const b = $('#save'); b.disabled = true; b.textContent = 'Saving…';
|
||||
const r = await fetch('/api/profile', {method:'POST', headers:{'content-type':'application/json'},
|
||||
body: JSON.stringify({id: c.visitor_id, full_name: $('#f-name').value, phone: $('#f-phone').value})});
|
||||
const d = await r.json();
|
||||
b.disabled = false; b.textContent = 'Save to the customer record';
|
||||
$('#savedmsg').innerHTML = (d.status===200||d.status===204)
|
||||
? '<div class="saved">✓ Saved — PUT /api/visitors/'+String(c.visitor_id).slice(0,8)+'…/profile → '+d.status+'</div>'
|
||||
: '<div class="saved" style="color:var(--bad)">'+(d.body&&d.body.message||('HTTP '+d.status))+'</div>';
|
||||
savedFor = c.visitor_id;
|
||||
};
|
||||
}
|
||||
|
||||
async function tick(){
|
||||
let s;
|
||||
try { s = await (await fetch('/api/snapshot')).json(); } catch { return; }
|
||||
|
||||
const eng = s.engine || {};
|
||||
setPill('#d-eng','#t-eng', eng.up ? 'ok' : 'bad', eng.up ? ('engine · ' + (eng.model||'starting')) : 'engine starting…');
|
||||
const cam = (eng.cameras||[])[0];
|
||||
setPill('#d-cam','#t-cam', cam && cam.connected ? 'ok' : 'warn',
|
||||
cam ? (cam.connected ? `camera live · ${cam.frames||0} frames` : 'camera connecting…') : 'no camera yet');
|
||||
setPill('#d-cloud','#t-cloud', s.cloud_ok ? 'ok' : 'bad', s.cloud_ok ? 'cloud connected' : 'cloud unreachable');
|
||||
$('#camname').textContent = cam ? cam.id : '';
|
||||
|
||||
if (cam && cam.connected && camStarted !== cam.id){
|
||||
camStarted = cam.id;
|
||||
$('#feed').src = '/camera.mjpeg?id=' + encodeURIComponent(cam.id);
|
||||
$('#feed').style.display = 'block'; $('#feednone').style.display = 'none';
|
||||
$('#camtag').style.display = 'block'; $('#camtag').textContent = cam.id;
|
||||
}
|
||||
|
||||
const e = (s.chain||[])[0];
|
||||
drawChain(e); drawCustomer(e);
|
||||
|
||||
$('#m-st').textContent = s.mobile.status;
|
||||
$('#m-body').textContent = JSON.stringify(s.mobile.body, null, 1).slice(0, 2600);
|
||||
$('#d-st').textContent = s.dashboard.status;
|
||||
$('#d-body').textContent = JSON.stringify(s.dashboard.body, null, 1).slice(0, 1800);
|
||||
}
|
||||
tick(); setInterval(tick, 1500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
308
demo/console.py
Normal file
308
demo/console.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""A one-screen live demo of the whole Behavision chain, for showing someone.
|
||||
|
||||
Run it on the shop PC (here, this Mac) while the engine and agent are running.
|
||||
It holds every credential itself and the browser holds none, so the page can be
|
||||
put on a projector without putting a token on it.
|
||||
|
||||
What it shows, and why each part is there:
|
||||
|
||||
- the live camera, so the person walking past sees themselves;
|
||||
- the CHAIN, measured rather than described: the engine recognised a face at
|
||||
this instant, the same visit appeared in the cloud API this many seconds
|
||||
later. That number is the product's claim, and it is computed here from two
|
||||
independent sources rather than asserted;
|
||||
- the customer, editable - type a name, walk past again, watch the name come
|
||||
back through the cloud instead of "Visitor 5";
|
||||
- the raw JSON a phone and a dashboard receive, side by side, because a
|
||||
colleague's real question is "is this actually wired up or is it a mock".
|
||||
|
||||
.venv/bin/python demo/console.py # http://127.0.0.1:8099
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
STATE = Path(os.environ.get("BEHAVISION_DATA_DIR", ROOT / ".demo"))
|
||||
CLOUD = os.environ.get("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")
|
||||
ENGINE = "http://127.0.0.1:8010"
|
||||
PORT = int(os.environ.get("DEMO_PORT", "8099"))
|
||||
|
||||
# Whoever the demo signs in as. Staff on purpose: it is the weakest role that
|
||||
# can do everything the shop floor does, so nothing here is only possible
|
||||
# because we used an owner.
|
||||
EMAIL = os.environ.get("DEMO_EMAIL", "staff.demo@tenext.in")
|
||||
PASSWORD = os.environ.get("DEMO_PASSWORD", "admin@123")
|
||||
|
||||
|
||||
def engine_auth() -> str:
|
||||
"""The engine invents a Basic credential when none is configured, and
|
||||
writes it here. Read it rather than keeping a second copy."""
|
||||
f = STATE / "data" / "api_credentials.txt"
|
||||
if not f.exists():
|
||||
return ""
|
||||
user = pw = ""
|
||||
for line in f.read_text().splitlines():
|
||||
# `key=value`, and `key: value` too - the engine writes one and people
|
||||
# read the other, and which is which is not worth a support call.
|
||||
if "=" in line or ":" in line:
|
||||
k, v = line.split("=", 1) if "=" in line else line.split(":", 1)
|
||||
if k.strip().lower() == "username":
|
||||
user = v.strip()
|
||||
elif k.strip().lower() == "password":
|
||||
pw = v.strip()
|
||||
if not user:
|
||||
return ""
|
||||
return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||
|
||||
|
||||
def fetch(url: str, *, headers=None, body=None, method="GET", timeout=20):
|
||||
req = urllib.request.Request(url, method=method,
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={k: v for k, v in (headers or {}).items() if v})
|
||||
if body is not None:
|
||||
req.add_header("content-type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw and r.headers.get("content-type", "").startswith("application/json") else raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
return e.code, json.loads(raw or b"{}")
|
||||
except Exception:
|
||||
return e.code, raw[:400]
|
||||
except Exception as e:
|
||||
return 0, {"error": str(e)}
|
||||
|
||||
|
||||
class Cloud:
|
||||
"""The signed-in session, refreshed when it expires."""
|
||||
|
||||
def __init__(self):
|
||||
self.token = ""
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def sign_in(self) -> bool:
|
||||
st, d = fetch(f"{CLOUD}/api/auth/login", method="POST",
|
||||
body={"email": EMAIL, "password": PASSWORD, "device": "Demo console"})
|
||||
if st == 200 and isinstance(d, dict):
|
||||
self.token = d.get("access_token", "")
|
||||
return True
|
||||
return False
|
||||
|
||||
def call(self, path, method="GET", body=None, retry=True):
|
||||
with self.lock:
|
||||
if not self.token and not self.sign_in():
|
||||
return 0, {"error": "cannot sign in to the platform"}
|
||||
tok = self.token
|
||||
st, d = fetch(f"{CLOUD}{path}", method=method, body=body,
|
||||
headers={"authorization": f"Bearer {tok}"})
|
||||
if st == 401 and retry:
|
||||
with self.lock:
|
||||
self.sign_in()
|
||||
return self.call(path, method, body, retry=False)
|
||||
return st, d
|
||||
|
||||
|
||||
cloud = Cloud()
|
||||
|
||||
# The chain, as the watcher builds it. One dict per recognition, newest first.
|
||||
events: list[dict] = []
|
||||
events_lock = threading.Lock()
|
||||
|
||||
|
||||
def watch():
|
||||
"""Poll the engine's own event log and the cloud feed, and join them.
|
||||
|
||||
They are joined on the identity and the second, not on a shared id,
|
||||
because the engine numbers identities locally and the server numbers them
|
||||
per tenant - the two are deliberately different (see CLAUDE.md). What
|
||||
matters for the demo is the LATENCY between one seeing a person and the
|
||||
other, and that only needs the same person and the same moment.
|
||||
"""
|
||||
seen_local: set[str] = set()
|
||||
while True:
|
||||
try:
|
||||
auth = engine_auth()
|
||||
st, d = fetch(f"{ENGINE}/api/events?limit=25", headers={"authorization": auth})
|
||||
if st == 200 and isinstance(d, dict):
|
||||
for e in d.get("events", []):
|
||||
if e.get("type") not in ("person.new", "person.seen"):
|
||||
continue
|
||||
key = f"{e.get('ts')}|{e.get('camera_id')}|{(e.get('data') or {}).get('identity_id')}"
|
||||
if key in seen_local:
|
||||
continue
|
||||
seen_local.add(key)
|
||||
data = e.get("data") or {}
|
||||
with events_lock:
|
||||
events.insert(0, {
|
||||
"key": key,
|
||||
"at": time.time(),
|
||||
"kind": "new" if e["type"] == "person.new" else "seen",
|
||||
"engine": {
|
||||
"label": data.get("label"),
|
||||
"identity_id": data.get("identity_id"),
|
||||
"similarity": data.get("similarity"),
|
||||
"quality": data.get("quality"),
|
||||
"gender": data.get("gender"),
|
||||
"age": data.get("age"),
|
||||
"camera": e.get("camera_id"),
|
||||
"ts": e.get("ts"),
|
||||
},
|
||||
"cloud": None,
|
||||
"latency": None,
|
||||
})
|
||||
del events[40:]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# the other half: has the cloud got it yet?
|
||||
try:
|
||||
with events_lock:
|
||||
pending = [e for e in events if e["cloud"] is None][:6]
|
||||
if pending:
|
||||
st, d = cloud.call("/api/visits?limit=12")
|
||||
arrivals = (d or {}).get("arrivals", []) if isinstance(d, dict) else []
|
||||
for e in pending:
|
||||
for a in arrivals:
|
||||
# same camera, and the cloud's visit is not older than
|
||||
# the engine's sighting
|
||||
if a.get("camera_id") != e["engine"]["camera"]:
|
||||
continue
|
||||
if a.get("visit_id") in [x["cloud"].get("visit_id") for x in events if x["cloud"]]:
|
||||
continue
|
||||
with events_lock:
|
||||
e["cloud"] = {
|
||||
"visit_id": a.get("visit_id"),
|
||||
"visitor_id": a.get("visitor_id"),
|
||||
"label": a.get("label"),
|
||||
"ref": a.get("customer_ref") or a.get("ref"),
|
||||
"is_new": a.get("is_new_visitor"),
|
||||
"similarity": a.get("similarity"),
|
||||
"site": a.get("site"),
|
||||
"occurred_at": a.get("occurred_at"),
|
||||
"image": a.get("image"),
|
||||
}
|
||||
e["latency"] = round(time.time() - e["at"], 1)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Everything the page draws, in one reply."""
|
||||
auth = engine_auth()
|
||||
_, health = fetch(f"{ENGINE}/api/health", headers={"authorization": auth})
|
||||
_, stats = fetch(f"{ENGINE}/api/stats", headers={"authorization": auth})
|
||||
st_v, visits = cloud.call("/api/visits?limit=3")
|
||||
st_f, foot = cloud.call("/api/reports/footfall?from=%s&to=%s"
|
||||
% (time.strftime("%Y-%m-%d", time.localtime(time.time() - 7 * 86400)),
|
||||
time.strftime("%Y-%m-%d")))
|
||||
with events_lock:
|
||||
chain = json.loads(json.dumps(events[:8]))
|
||||
cams = (stats or {}).get("cameras", []) if isinstance(stats, dict) else []
|
||||
return {
|
||||
"engine": {
|
||||
"up": isinstance(health, dict) and bool(health.get("status")),
|
||||
"model": (health or {}).get("recognition_model") if isinstance(health, dict) else None,
|
||||
"cameras": [{"id": c.get("camera_id"), "connected": c.get("connected"),
|
||||
"frames": c.get("frames"), "faces": c.get("faces")} for c in cams],
|
||||
},
|
||||
"cloud_ok": st_v == 200,
|
||||
"chain": chain,
|
||||
"mobile": {"request": "GET /api/visits?limit=3", "status": st_v, "body": visits},
|
||||
"dashboard": {"request": "GET /api/reports/footfall?from=…&to=…", "status": st_f, "body": foot},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
page = (Path(__file__).parent / "console.html").read_bytes()
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): # quiet
|
||||
pass
|
||||
|
||||
def _send(self, code, body, ctype="application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("content-type", ctype)
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
u = urlparse(self.path)
|
||||
if u.path == "/":
|
||||
return self._send(200, page, "text/html; charset=utf-8")
|
||||
if u.path == "/api/snapshot":
|
||||
return self._send(200, json.dumps(snapshot()).encode())
|
||||
if u.path == "/api/customer":
|
||||
vid = parse_qs(u.query).get("id", [""])[0]
|
||||
if not vid:
|
||||
return self._send(400, b'{"error":"no id"}')
|
||||
st, d = cloud.call(f"/api/visitors/{vid}/history?limit=8")
|
||||
return self._send(200, json.dumps({"status": st, "history": d}).encode())
|
||||
if u.path == "/camera.mjpeg":
|
||||
cam = parse_qs(u.query).get("id", [""])[0]
|
||||
return self._proxy_stream(f"{ENGINE}/api/cameras/{cam}/stream.mjpeg")
|
||||
self._send(404, b'{"error":"no"}')
|
||||
|
||||
def do_POST(self):
|
||||
u = urlparse(self.path)
|
||||
n = int(self.headers.get("content-length", 0))
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
if u.path == "/api/profile":
|
||||
vid = body.pop("id", "")
|
||||
st, d = cloud.call(f"/api/visitors/{vid}/profile", method="PUT", body=body)
|
||||
return self._send(200, json.dumps({"status": st, "body": d}).encode())
|
||||
self._send(404, b'{"error":"no"}')
|
||||
|
||||
def _proxy_stream(self, url):
|
||||
"""The engine's MJPEG, relayed so the browser needs no credential.
|
||||
|
||||
The engine's API is Basic-authenticated with a credential it
|
||||
generated locally; putting that in a page would hand the whole
|
||||
biometric API to anyone who opened it.
|
||||
"""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"authorization": engine_auth()})
|
||||
up = urllib.request.urlopen(req, timeout=20)
|
||||
except Exception:
|
||||
return self._send(502, b'{"error":"camera not available"}')
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", up.headers.get("content-type", "multipart/x-mixed-replace"))
|
||||
self.end_headers()
|
||||
try:
|
||||
while True:
|
||||
chunk = up.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
up.close()
|
||||
|
||||
threading.Thread(target=watch, daemon=True).start()
|
||||
print(f"\n Demo console → http://127.0.0.1:{PORT}\n")
|
||||
print(f" engine {ENGINE} · cloud {CLOUD} · signed in as {EMAIL}\n")
|
||||
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -66,7 +66,7 @@ func NewApp() *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
||||
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
||||
local: localWithCreds(base, cfg),
|
||||
proxy: newStreamProxy(),
|
||||
}
|
||||
}
|
||||
@@ -815,3 +815,12 @@ func envOr(key, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// localWithCreds builds the engine client with a credential resolver, so a
|
||||
// first run - where the engine writes its credential after the app has looked
|
||||
// for it - recovers by itself instead of 401ing for the life of the process.
|
||||
func localWithCreds(base string, cfg agentcfg.Config) *local.Client {
|
||||
c := local.New(base, cfg.APIUser, cfg.APIPassword)
|
||||
c.Creds = agentcfg.NewCreds(agentpaths.APICredentials(), cfg.APIUser, cfg.APIPassword)
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
agentconfig "github.com/loyaly/behavision-agent/pkg/config"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -20,7 +21,11 @@ type Client struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
http *http.Client
|
||||
// Creds re-reads the engine's generated credential when one is rejected.
|
||||
// On a first run the app starts the engine, and the engine writes that
|
||||
// file seconds later - after the app has already looked for it.
|
||||
Creds *agentconfig.Creds
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(base, user, password string) *Client {
|
||||
@@ -48,8 +53,12 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.User != "" {
|
||||
req.SetBasicAuth(c.User, c.Password)
|
||||
user, pass := c.User, c.Password
|
||||
if c.Creds != nil {
|
||||
user, pass = c.Creds.Get()
|
||||
}
|
||||
if user != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user