adminui: give the admin UI the instrument's own visual language
Echolot is the German word for an echo sounder — an instrument that emits a ping and reads what comes back — and the UI now looks like one instead of like a dashboard. The three big-number stat cards went first: that layout is the stock answer for any admin page, and it told a network engineer nothing they could act on. Palette is a water column rather than a neutral near-black, with a desaturated sea-green return for the accent. Verdict colours come from the domain, so they carry meaning rather than decorate. No web fonts — the CSP forbids loading anything and shipping font files would trade what makes this a single pleasant binary for a typeface — so the character comes from treatment: machine-set headings, tracked small caps, hairlines. The one ornament is a trace of returns across time on the runs page, one bar per run coloured by verdict, oldest to newest. It is real data, pure CSS, and it is what an echo sounder actually draws. The mobile fix is the same idea rather than a fallback. Tables become label-and-value records with dotted leaders, which is how a sounding log prints and is easier to read on a phone than any table that scrolls sideways. Above 46rem every row shares one grid so the columns agree by construction; the first attempt used table-cell and each row wrapped independently, which produces a table that does not align — a list paying for borders. Found by rendering it rather than reading the CSS: the trace stranded itself against the right edge when runs were few, "Open run" broke across two lines, equal columns wrapped device names while a one-digit count kept a quarter of the row, and the sign-in page carried no wordmark at all, so you arrived somewhere that never said what it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
621ee99b77
commit
332b6f3589
+270
-110
@@ -8,12 +8,40 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Templates are parsed once at start. html/template escapes by context, which is what makes it
|
// Templates are parsed once at start. html/template escapes by context, which is what makes it
|
||||||
// safe to render device names and finding text that ultimately arrived over a network.
|
// safe to render device names and finding text that ultimately arrived over a network.
|
||||||
var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
|
var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
|
||||||
"kb": func(n int64) int64 { return n / 1024 },
|
"kb": func(n int64) int64 { return n / 1024 },
|
||||||
|
// verdictClass keeps an uploaded string out of the class attribute. The verdict arrives inside
|
||||||
|
// a document a device sent us, so interpolating it into markup would be trusting a stranger's
|
||||||
|
// text with a place in the stylesheet; mapping through a fixed set costs nothing and closes it.
|
||||||
|
"verdictClass": func(v string) string {
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "green", "yellow", "red", "inconclusive":
|
||||||
|
return "v-" + strings.ToLower(v)
|
||||||
|
default:
|
||||||
|
return "v-unknown"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// verdictLabel says what the light means rather than what it is called. "yellow" is a colour;
|
||||||
|
// "worth a look" is a finding, and the reader is here to act on it.
|
||||||
|
"verdictLabel": func(v string) string {
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "green":
|
||||||
|
return "clean"
|
||||||
|
case "yellow":
|
||||||
|
return "worth a look"
|
||||||
|
case "red":
|
||||||
|
return "faults found"
|
||||||
|
case "inconclusive":
|
||||||
|
return "inconclusive"
|
||||||
|
default:
|
||||||
|
return "not recorded"
|
||||||
|
}
|
||||||
|
},
|
||||||
}).Parse(baseHTML))
|
}).Parse(baseHTML))
|
||||||
|
|
||||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
|
||||||
@@ -33,119 +61,228 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat
|
|||||||
_, _ = buf.WriteTo(w)
|
_, _ = buf.WriteTo(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The visual language is an echo sounder's, which is what the name means: an instrument that emits
|
||||||
|
// a ping and reads what comes back. That gives the palette (the colours of a water column rather
|
||||||
|
// than a neutral near-black), the type (machine-set, because an instrument's readings are), and
|
||||||
|
// the one piece of real ornament — a trace of returns across time on the runs page.
|
||||||
|
//
|
||||||
|
// No web fonts: the CSP forbids loading anything, and shipping font files with a single Go binary
|
||||||
|
// would trade the property that makes this server pleasant to run for a typeface. So the character
|
||||||
|
// has to come from treatment — tracking, case, scale, rules — rather than from novel letterforms.
|
||||||
|
//
|
||||||
|
// Tables become stacked records below 46rem rather than scrolling sideways. That is not a fallback:
|
||||||
|
// a sounding log prints as label-and-value pairs, and on a phone that form is easier to read than
|
||||||
|
// any table, so the mobile layout is the more faithful one of the two.
|
||||||
const baseHTML = `<!doctype html>
|
const baseHTML = `<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8">
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Echolot — {{.Page}}</title>
|
<title>Echolot — {{.Page}}</title>
|
||||||
<style>
|
<style>
|
||||||
:root{color-scheme:dark}
|
:root{
|
||||||
|
--abyss:#071419; --hull:#0d2028; --raise:#122a34; --rule:#17323d;
|
||||||
|
--ink:#dce8ea; --dim:#7d97a1; --trace:#6fc9b4;
|
||||||
|
--green:#57ad82; --amber:#cf9b3c; --red:#c25757; --slate:#62767f;
|
||||||
|
--mono:ui-monospace,"SF Mono","IBM Plex Mono","JetBrains Mono",Menlo,Consolas,monospace;
|
||||||
|
--prose:system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||||
|
color-scheme:dark;
|
||||||
|
}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
body{font:15px/1.5 system-ui,sans-serif;margin:0;background:#14161a;color:#e6e6e6}
|
body{margin:0;background:var(--abyss);color:var(--ink);
|
||||||
/* The header wraps rather than overflowing: on a phone a rigid flex row pushes the account name
|
font:400 15px/1.55 var(--prose);-webkit-text-size-adjust:100%}
|
||||||
and the sign-out button off the side of the screen, where they cannot be reached at all. */
|
|
||||||
header{display:flex;gap:.6rem 1.2rem;align-items:baseline;flex-wrap:wrap;
|
/* ---- masthead ------------------------------------------------------------------------ */
|
||||||
padding:.8rem 1.2rem;background:#1c1f25;border-bottom:1px solid #2b2f36}
|
/* Wraps rather than overflows: a rigid row pushes the account and its sign-out button past
|
||||||
header h1{font-size:1.1rem;margin:0;font-weight:600}
|
the edge of a phone screen, where they cannot be reached at all. */
|
||||||
header nav{display:flex;flex-wrap:wrap;gap:.2rem 1rem}
|
.top{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1.4rem;
|
||||||
header nav a{color:#9ecbff;text-decoration:none}
|
padding:.85rem 1.1rem;background:var(--hull);border-bottom:1px solid var(--rule)}
|
||||||
header .who{margin-left:auto;color:#9aa3ad;font-size:.9rem;
|
.mark{font:600 .95rem/1 var(--mono);letter-spacing:.02em;margin:0;color:var(--ink)}
|
||||||
display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
|
.mark span{color:var(--trace)}
|
||||||
main{padding:1.2rem;max-width:70rem}
|
.top nav{display:flex;flex-wrap:wrap;gap:.15rem .9rem}
|
||||||
table{border-collapse:collapse;width:100%;margin:0}
|
.top nav a{font:500 .82rem/1 var(--mono);letter-spacing:.06em;color:var(--dim);
|
||||||
th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid #2b2f36;vertical-align:top}
|
text-decoration:none;padding:.35rem 0;border-bottom:1px solid transparent}
|
||||||
th{color:#9aa3ad;font-weight:500;font-size:.85rem}
|
.top nav a:hover{color:var(--ink)}
|
||||||
/* Wide content scrolls inside its own box. Letting the page scroll sideways instead makes every
|
.top nav a[aria-current]{color:var(--trace);border-bottom-color:var(--trace)}
|
||||||
other column of text unreadable, and on a phone it is not obvious that it happened. */
|
.who{margin-left:auto;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;
|
||||||
.tablewrap{overflow-x:auto;margin:.6rem 0;-webkit-overflow-scrolling:touch}
|
font:.78rem/1.3 var(--mono);color:var(--dim)}
|
||||||
.tablewrap table{min-width:32rem}
|
|
||||||
code,pre{font-family:ui-monospace,monospace;font-size:.85rem}
|
main{padding:1.1rem;max-width:64rem}
|
||||||
code{overflow-wrap:anywhere}
|
|
||||||
pre{background:#0f1114;padding:.8rem;border-radius:6px;overflow:auto;max-height:34rem;max-width:100%}
|
/* ---- headings: a graduation mark, like a depth scale ------------------------------- */
|
||||||
.card{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:1rem;margin:.8rem 0;
|
h2{font:500 1.05rem/1.2 var(--mono);letter-spacing:-.01em;margin:1.4rem 0 .2rem;
|
||||||
min-width:0}
|
padding-left:.7rem;border-left:2px solid var(--trace)}
|
||||||
.grid{display:flex;gap:1rem;flex-wrap:wrap}
|
h2:first-child{margin-top:0}
|
||||||
.stat{background:#1c1f25;border:1px solid #2b2f36;border-radius:8px;padding:.8rem 1.2rem;
|
h3{font:500 .8rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
|
||||||
flex:1 1 8rem;min-width:8rem}
|
color:var(--dim);margin:0 0 .7rem}
|
||||||
.stat b{display:block;font-size:1.6rem;font-weight:600}
|
.lede{color:var(--dim);font-size:.9rem;margin:.5rem 0 1rem;max-width:46rem}
|
||||||
.stat span{color:#9aa3ad;font-size:.85rem}
|
|
||||||
button{font:inherit;background:#2d6cdf;color:#fff;border:0;border-radius:6px;padding:.4rem .8rem;cursor:pointer}
|
/* ---- readout: how an instrument prints a value ------------------------------------- */
|
||||||
button.danger{background:#8b2f2f}
|
.readout{list-style:none;margin:0;padding:0}
|
||||||
button.plain{background:#3a3f47}
|
.readout li{display:flex;align-items:baseline;gap:.5rem;padding:.3rem 0;
|
||||||
input{font:inherit;background:#0f1114;color:#e6e6e6;border:1px solid #2b2f36;border-radius:6px;
|
font:.85rem/1.4 var(--mono)}
|
||||||
padding:.4rem .6rem;max-width:100%}
|
.readout .k{color:var(--dim);white-space:nowrap}
|
||||||
.err{background:#3a1f1f;border:1px solid #7a3b3b;padding:.6rem .8rem;border-radius:6px}
|
/* The dotted leader is how a sounding log runs a label out to its value. It is also the thing
|
||||||
.muted{color:#9aa3ad}
|
that lets a label and a number sit on one line at any width without a table. */
|
||||||
|
.readout .lead{flex:1 1 auto;min-width:1.5rem;align-self:center;height:1px;
|
||||||
|
background:repeating-linear-gradient(90deg,var(--rule) 0 2px,transparent 2px 5px)}
|
||||||
|
.readout .v{color:var(--ink);text-align:right;overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
/* ---- the trace: one bar per run, oldest to newest ---------------------------------- */
|
||||||
|
/* The signature, and the only ornament here: an echo sounder draws returns against time, and
|
||||||
|
so does this. Rows arrive newest-first, so the strip is reversed in CSS rather than in Go. */
|
||||||
|
.trace{display:flex;flex-direction:row-reverse;justify-content:flex-end;align-items:flex-end;gap:2px;
|
||||||
|
height:3rem;padding:.7rem .8rem;background:var(--hull);
|
||||||
|
border:1px solid var(--rule);border-radius:3px;overflow:hidden}
|
||||||
|
.trace i{flex:1 1 3px;min-width:2px;max-width:9px;border-radius:1px;opacity:.9}
|
||||||
|
.trace .v-green{height:35%;background:var(--green)}
|
||||||
|
.trace .v-yellow{height:65%;background:var(--amber)}
|
||||||
|
.trace .v-red{height:100%;background:var(--red)}
|
||||||
|
.trace .v-inconclusive{height:22%;background:var(--slate)}
|
||||||
|
.trace .v-unknown{height:12%;background:var(--rule)}
|
||||||
|
.trace-key{display:flex;flex-wrap:wrap;gap:.3rem .9rem;margin:.45rem 0 0;
|
||||||
|
font:.72rem/1 var(--mono);letter-spacing:.05em;color:var(--dim)}
|
||||||
|
.trace-key b{font-weight:400;color:var(--dim)}
|
||||||
|
.trace-key em{font-style:normal;display:inline-block;width:.5rem;height:.5rem;
|
||||||
|
border-radius:1px;margin-right:.35rem;vertical-align:baseline;background:var(--rule)}
|
||||||
|
.trace-key em.v-green{background:var(--green)}
|
||||||
|
.trace-key em.v-yellow{background:var(--amber)}
|
||||||
|
.trace-key em.v-red{background:var(--red)}
|
||||||
|
.trace-key em.v-inconclusive{background:var(--slate)}
|
||||||
|
|
||||||
|
/* ---- records: tables that stack on a phone ----------------------------------------- */
|
||||||
|
.rec{border:1px solid var(--rule);border-radius:3px;background:var(--hull);
|
||||||
|
padding:.75rem .85rem;margin:.5rem 0}
|
||||||
|
.rec-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem;
|
||||||
|
font:.85rem/1.3 var(--mono);margin-bottom:.35rem}
|
||||||
|
.rec-head .id{overflow-wrap:anywhere;color:var(--ink)}
|
||||||
|
.rec form{margin-top:.6rem}
|
||||||
|
.tag{font:.68rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
|
||||||
|
padding:.24rem .45rem;border-radius:2px;border:1px solid currentColor;white-space:nowrap}
|
||||||
|
.v-green{color:var(--green)} .v-yellow{color:var(--amber)}
|
||||||
|
.v-red{color:var(--red)} .v-inconclusive{color:var(--slate)} .v-unknown{color:var(--dim)}
|
||||||
|
|
||||||
|
/* ---- panels, controls, states ------------------------------------------------------ */
|
||||||
|
.narrow{max-width:27rem}
|
||||||
|
.panel{background:var(--hull);border:1px solid var(--rule);border-radius:3px;
|
||||||
|
padding:.95rem 1rem;margin:.9rem 0;min-width:0}
|
||||||
|
.empty{border:1px dashed var(--rule);border-radius:3px;padding:1.4rem 1rem;
|
||||||
|
color:var(--dim);font-size:.9rem}
|
||||||
|
code,pre,.mono{font-family:var(--mono);font-size:.82rem}
|
||||||
|
code{overflow-wrap:anywhere;color:var(--trace)}
|
||||||
|
pre{background:#040d11;border:1px solid var(--rule);border-radius:3px;padding:.8rem;
|
||||||
|
overflow:auto;max-height:32rem;max-width:100%;color:var(--ink)}
|
||||||
|
a{color:var(--trace)}
|
||||||
|
button{font:500 .82rem/1 var(--mono);letter-spacing:.05em;background:var(--trace);
|
||||||
|
color:#04181a;border:0;border-radius:3px;padding:.55rem .9rem;cursor:pointer}
|
||||||
|
.btn{display:inline-block;font:500 .82rem/1 var(--mono);letter-spacing:.05em;
|
||||||
|
background:var(--trace);color:#04181a;border-radius:3px;padding:.55rem .9rem;
|
||||||
|
text-decoration:none}
|
||||||
|
button.plain{background:transparent;color:var(--dim);border:1px solid var(--rule)}
|
||||||
|
button.danger{background:transparent;color:var(--red);border:1px solid var(--red)}
|
||||||
|
button:hover{filter:brightness(1.08)}
|
||||||
|
input{font:.9rem var(--mono);background:#040d11;color:var(--ink);border:1px solid var(--rule);
|
||||||
|
border-radius:3px;padding:.55rem .6rem;max-width:100%;width:100%}
|
||||||
|
label{display:block;font:.72rem/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;
|
||||||
|
color:var(--dim);margin:.9rem 0 .3rem}
|
||||||
|
.err{border:1px solid var(--red);color:var(--ink);background:rgba(194,87,87,.09);
|
||||||
|
padding:.6rem .75rem;border-radius:3px;font-size:.9rem}
|
||||||
|
.muted{color:var(--dim)}
|
||||||
form.inline{display:inline}
|
form.inline{display:inline}
|
||||||
@media (max-width:40rem){
|
:focus-visible{outline:2px solid var(--trace);outline-offset:2px}
|
||||||
main{padding:.9rem}
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
||||||
header{padding:.7rem .9rem}
|
|
||||||
/* Full width on its own row, so the name can be long without stealing the nav's space. */
|
/* ---- above 46rem the records line up in columns ------------------------------------ */
|
||||||
header .who{margin-left:0;width:100%;justify-content:space-between}
|
@media (min-width:46rem){
|
||||||
/* Touch targets: .4rem of padding is comfortable with a mouse and fiddly with a thumb. */
|
.top{padding:.85rem 1.6rem}
|
||||||
button{padding:.5rem .9rem}
|
main{padding:1.6rem}
|
||||||
.stat{padding:.7rem .9rem}
|
.recs{margin:.8rem 0}
|
||||||
.stat b{font-size:1.4rem}
|
/* Every row shares one grid, so the columns agree across rows without a header or a table. */
|
||||||
|
.rec{display:grid;grid-template-columns:12.5rem minmax(0,1fr) auto;gap:.35rem 1.4rem;
|
||||||
|
align-items:baseline;background:none;border:0;border-bottom:1px solid var(--rule);
|
||||||
|
border-radius:0;padding:.6rem 0;margin:0}
|
||||||
|
.rec-head{margin:0;flex-direction:column;align-items:flex-start;gap:.3rem}
|
||||||
|
.rec form{margin:0}
|
||||||
|
/* Widths follow the content: a device name needs room, a finding count does not. */
|
||||||
|
.readout{display:grid;grid-template-columns:1.7fr .9fr .9fr 1.1fr;gap:.15rem 1.2rem}
|
||||||
|
.readout li{padding:0}
|
||||||
|
.readout .lead{display:none}
|
||||||
|
.readout .v{text-align:left}
|
||||||
|
.open{white-space:nowrap}
|
||||||
}
|
}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
{{if ne .Page "login"}}
|
<header class="top">
|
||||||
<header>
|
<h1 class="mark">echo<span>lot</span></h1>
|
||||||
<h1>Echolot</h1>
|
{{if ne .Page "login"}}
|
||||||
<nav><a href="/">Overview</a><a href="/devices">Devices</a><a href="/runs">Runs</a></nav>
|
<nav>
|
||||||
<span class="who">{{.Session.Display}}{{if not .Session.Admin}} <span class="muted">(your account)</span>{{end}}
|
<a href="/"{{if eq .Page "dashboard"}} aria-current="page"{{end}}>overview</a>
|
||||||
|
<a href="/devices"{{if eq .Page "devices"}} aria-current="page"{{end}}>devices</a>
|
||||||
|
<a href="/runs"{{if eq .Page "runs"}} aria-current="page"{{end}}>runs</a>
|
||||||
|
</nav>
|
||||||
|
<span class="who">{{.Session.Display}}{{if not .Session.Admin}} · your account{{end}}
|
||||||
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
|
<form method="post" action="/logout" class="inline"><button class="plain">Sign out</button></form>
|
||||||
</span>
|
</span>
|
||||||
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
{{end}}
|
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
{{if eq .Page "login"}}
|
{{if eq .Page "login"}}
|
||||||
<h2>Sign in</h2>
|
<h2>Sign in</h2>
|
||||||
{{with .Error}}<p class="err">{{.}}</p>{{end}}
|
<p class="lede">This server keeps the measurements your devices have uploaded.</p>
|
||||||
{{if .OIDC}}
|
{{if .OIDC}}
|
||||||
<p><a href="/auth/start"><button>Sign in with your identity provider</button></a></p>
|
<p><a class="btn" href="/auth/start">Sign in with your identity provider</a></p>
|
||||||
<p class="muted">or use the break-glass account:</p>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .LocalSet}}
|
{{if .LocalSet}}
|
||||||
<form method="post" action="/login" class="card">
|
<form method="post" action="/login" class="panel narrow">
|
||||||
<p><label>Username<br><input name="username" value="{{.AdminUser}}" autocomplete="username"></label></p>
|
<h3>Break-glass account</h3>
|
||||||
<p><label>Password<br><input name="password" type="password" autocomplete="current-password"></label></p>
|
<label for="u">Username</label>
|
||||||
|
<input id="u" name="username" autocomplete="username" value="{{.AdminUser}}">
|
||||||
|
<label for="p">Password</label>
|
||||||
|
<input id="p" name="password" type="password" autocomplete="current-password">
|
||||||
<p><button>Sign in</button></p>
|
<p><button>Sign in</button></p>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="err">No break-glass admin is set. Run
|
<p class="err">No break-glass account is set. Run
|
||||||
<code>echolot-server --set-admin-password</code> on the host.</p>
|
<code>echolot-server --set-admin-password</code> on the host to create one.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{else if eq .Page "dashboard"}}
|
{{else if eq .Page "dashboard"}}
|
||||||
<div class="grid">
|
<h2>{{if .Admin}}This server{{else}}Your account{{end}}</h2>
|
||||||
<div class="stat"><b>{{.Devices}}</b><span>{{if .Admin}}devices{{else}}your devices{{end}}</span></div>
|
<ul class="readout panel">
|
||||||
{{if .Admin}}<div class="stat"><b>{{.Linked}}</b><span>signed in</span></div>{{end}}
|
<li><span class="k">{{if .Admin}}devices enrolled{{else}}your devices{{end}}</span>
|
||||||
<div class="stat"><b>{{.Runs}}</b><span>{{if .Admin}}stored runs{{else}}your runs{{end}}</span></div>
|
<span class="lead"></span><span class="v">{{.Devices}}</span></li>
|
||||||
</div>
|
{{if .Admin}}
|
||||||
|
<li><span class="k">linked to an account</span>
|
||||||
|
<span class="lead"></span><span class="v">{{.Linked}}</span></li>
|
||||||
|
{{end}}
|
||||||
|
<li><span class="k">{{if .Admin}}runs stored{{else}}your runs{{end}}</span>
|
||||||
|
<span class="lead"></span><span class="v">{{.Runs}}</span></li>
|
||||||
|
{{if .Admin}}
|
||||||
|
<li><span class="k">server version</span>
|
||||||
|
<span class="lead"></span><span class="v">{{.Version}}</span></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
{{if not .Admin}}
|
{{if not .Admin}}
|
||||||
<div class="card">
|
<div class="panel">
|
||||||
<p>This is your account. You can see the devices you have signed in on, review everything
|
<p>You can see every device you have signed in on, read everything they have uploaded, and
|
||||||
they have uploaded, and delete any of it.</p>
|
delete any of it.</p>
|
||||||
<p class="muted">Administering the server — enrolling devices, revoking them, and
|
<p class="muted">Enrolling devices, revoking them, and reading other people's uploads need an
|
||||||
seeing other people's uploads — needs an administrator account.</p>
|
administrator account.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Admin}}
|
{{with .SelfTest}}
|
||||||
<div class="card">
|
<h2>Self-test</h2>
|
||||||
<h3>Server</h3>
|
<p class="lede">What this server can measure from where it stands. A capability missing here is
|
||||||
<p class="muted">version {{.Version}}</p>
|
missing from every run it takes part in.</p>
|
||||||
{{with .SelfTest}}<pre>{{printf "%+v" .}}</pre>{{end}}
|
<pre>{{printf "%+v" .}}</pre>
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{else if eq .Page "devices"}}
|
{{else if eq .Page "devices"}}
|
||||||
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
||||||
{{with .Link}}
|
{{with .Link}}
|
||||||
<div class="card">
|
<div class="panel">
|
||||||
<p><b>Enrolment link</b> — single use, valid 24 hours. Treat it like a password until spent.</p>
|
<h3>Enrolment link</h3>
|
||||||
|
<p class="lede">Single use, valid 24 hours. Treat it like a password until it is spent.</p>
|
||||||
<p><code>{{.}}</code></p>
|
<p><code>{{.}}</code></p>
|
||||||
<p class="muted">On a device with adb:<br>
|
<p class="muted mono">adb shell am start -a android.intent.action.VIEW -d "{{.}}"</p>
|
||||||
<code>adb shell am start -a android.intent.action.VIEW -d "{{.}}"</code></p>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Admin}}
|
{{if .Admin}}
|
||||||
@@ -154,48 +291,71 @@ const baseHTML = `<!doctype html>
|
|||||||
<button>Create enrolment link</button>
|
<button>Create enrolment link</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="tablewrap"><table>
|
{{if .Rows}}<div class="recs">
|
||||||
<tr><th>Device</th><th>Name</th><th>Account</th><th>Enrolled</th><th>Runs</th><th></th></tr>
|
|
||||||
{{range .Rows}}
|
{{range .Rows}}
|
||||||
<tr>
|
<div class="rec">
|
||||||
<td><code>{{.ID}}</code></td>
|
<div class="rec-head">
|
||||||
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">—</span>{{end}}</td>
|
<span class="id">{{if .Name}}{{.Name}}{{else}}{{.ID}}{{end}}</span>
|
||||||
<td>{{if .LinkedToAccount}}{{.AccountName}}{{else}}<span class="muted">not signed in</span>{{end}}</td>
|
{{if .LinkedToAccount}}<span class="tag v-green">{{.AccountName}}</span>
|
||||||
<td>{{.Enrolled.Format "2006-01-02 15:04"}}</td>
|
{{else}}<span class="tag v-unknown">no account</span>{{end}}
|
||||||
<td>{{.Runs}}</td>
|
</div>
|
||||||
<td>{{if $.Admin}}<form method="post" action="/devices/{{.ID}}/revoke" class="inline">
|
<ul class="readout">
|
||||||
|
<li><span class="k">device</span><span class="lead"></span><span class="v">{{.ID}}</span></li>
|
||||||
|
<li><span class="k">enrolled</span><span class="lead"></span>
|
||||||
|
<span class="v">{{.Enrolled.Format "2006-01-02 15:04"}}</span></li>
|
||||||
|
<li><span class="k">runs</span><span class="lead"></span><span class="v">{{.Runs}}</span></li>
|
||||||
|
</ul>
|
||||||
|
{{if $.Admin}}
|
||||||
|
<form method="post" action="/devices/{{.ID}}/revoke">
|
||||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||||
<button class="danger">Revoke</button></form>{{end}}</td>
|
<button class="danger">Revoke</button>
|
||||||
</tr>
|
</form>
|
||||||
{{else}}
|
{{end}}
|
||||||
<tr><td colspan="6" class="muted">{{if $.Admin}}No devices enrolled.{{else}}You have not signed in on any device yet. Sign in from the Echolot app to link one.{{end}}</td></tr>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>{{else}}
|
||||||
|
<p class="empty">{{if .Admin}}No devices yet. Create an enrolment link and open it on the phone
|
||||||
|
you want to measure from.{{else}}No devices yet. Sign in from the Echolot app on your phone to
|
||||||
|
link one to this account.{{end}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</table></div>
|
|
||||||
|
|
||||||
{{else if eq .Page "runs"}}
|
{{else if eq .Page "runs"}}
|
||||||
<h2>{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}</h2>
|
<h2>{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}</h2>
|
||||||
<p class="muted">Shown exactly as uploaded, at the privacy level the uploader chose. Nothing
|
<p class="lede">Each run is shown exactly as it arrived, at the privacy level its uploader chose.
|
||||||
here can un-redact a run.</p>
|
Nothing here can un-redact one.</p>
|
||||||
<div class="tablewrap"><table>
|
{{if .Rows}}
|
||||||
<tr><th>Uploaded</th><th>Device</th><th>Verdict</th><th>Findings</th><th>Size</th><th>Level</th><th></th></tr>
|
<div class="trace">{{range .Rows}}<i class="{{verdictClass .Verdict}}"></i>{{end}}</div>
|
||||||
|
<p class="trace-key"><b>oldest → newest</b>
|
||||||
|
<b><em class="v-green"></em>clean</b>
|
||||||
|
<b><em class="v-yellow"></em>worth a look</b>
|
||||||
|
<b><em class="v-red"></em>faults</b>
|
||||||
|
<b><em class="v-inconclusive"></em>inconclusive</b></p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Rows}}<div class="recs">
|
||||||
{{range .Rows}}
|
{{range .Rows}}
|
||||||
<tr>
|
<div class="rec">
|
||||||
<td>{{.UploadedAt.Format "2006-01-02 15:04"}}</td>
|
<div class="rec-head">
|
||||||
<td>{{.DeviceName}}</td>
|
<span class="id">{{.UploadedAt.Format "2006-01-02 15:04"}}</span>
|
||||||
<td>{{if .Verdict}}{{.Verdict}}{{else}}<span class="muted">—</span>{{end}}</td>
|
<span class="tag {{verdictClass .Verdict}}">{{verdictLabel .Verdict}}</span>
|
||||||
<td>{{.FindingCount}}</td>
|
</div>
|
||||||
<td>{{kb .SizeBytes}} kB</td>
|
<ul class="readout">
|
||||||
<td>{{.Anonymization}}</td>
|
<li><span class="k">device</span><span class="lead"></span><span class="v">{{.DeviceName}}</span></li>
|
||||||
<td><a href="/runs/{{.DeviceID}}/{{.ID}}">open</a></td>
|
<li><span class="k">findings</span><span class="lead"></span><span class="v">{{.FindingCount}}</span></li>
|
||||||
</tr>
|
<li><span class="k">size</span><span class="lead"></span><span class="v">{{kb .SizeBytes}} kB</span></li>
|
||||||
{{else}}
|
<li><span class="k">privacy</span><span class="lead"></span><span class="v">{{.Anonymization}}</span></li>
|
||||||
<tr><td colspan="7" class="muted">Nothing uploaded yet.</td></tr>
|
</ul>
|
||||||
|
<div class="open"><a href="/runs/{{.DeviceID}}/{{.ID}}">Open run</a></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>{{else}}
|
||||||
|
<p class="empty">Nothing uploaded yet. Take a measurement in the app and upload it; it will
|
||||||
|
appear here.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</table></div>
|
|
||||||
|
|
||||||
{{else if eq .Page "run"}}
|
{{else if eq .Page "run"}}
|
||||||
<h2>Run {{.ID}}</h2>
|
<h2>Run {{.ID}}</h2>
|
||||||
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete" class="inline">
|
<p class="lede">The document as stored, indented for reading. Nothing has been added or removed.</p>
|
||||||
|
<form method="post" action="/runs/{{.Device}}/{{.ID}}/delete">
|
||||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||||
<button class="danger">Delete this run</button>
|
<button class="danger">Delete this run</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user