html/template rewrites an href whose scheme it does not recognise to "#ZgotmplZ". echolot:// is not on its list, so "Open in the Echolot app" was not a link at all — tapping it did nothing, and nothing showed it: the markup reads correctly, the app resolves the scheme, and only the sanitised attribute in the served HTML gives it away. Marking the value template.URL opts out of that sanitising, which is only safe because the shape is now checked first. The link arrives in a query parameter, so without the check a crafted /devices?link=javascript:… would put a script URL into the page for an admin to click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
416 lines
20 KiB
Go
416 lines
20 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package adminui
|
|
|
|
import (
|
|
"bytes"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// 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.
|
|
var tpl = template.Must(template.New("base").Funcs(template.FuncMap{
|
|
"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))
|
|
|
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
|
|
data["Page"] = page
|
|
var buf bytes.Buffer
|
|
if err := tpl.Execute(&buf, data); err != nil {
|
|
slog.Error("admin template", "page", page, "err", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
// There is no script here and nothing loaded from anywhere else, so a strict policy costs
|
|
// nothing and closes injected-script attacks even if an escaping bug ever slips through.
|
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
_, _ = 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>
|
|
<html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Echolot — {{.Page}}</title>
|
|
<style>
|
|
: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}
|
|
body{margin:0;background:var(--abyss);color:var(--ink);
|
|
font:400 15px/1.55 var(--prose);-webkit-text-size-adjust:100%}
|
|
|
|
/* ---- masthead ------------------------------------------------------------------------ */
|
|
/* Wraps rather than overflows: a rigid row pushes the account and its sign-out button past
|
|
the edge of a phone screen, where they cannot be reached at all. */
|
|
.top{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1.4rem;
|
|
padding:.85rem 1.1rem;background:var(--hull);border-bottom:1px solid var(--rule)}
|
|
.mark{font:600 .95rem/1 var(--mono);letter-spacing:.02em;margin:0;color:var(--ink)}
|
|
.mark span{color:var(--trace)}
|
|
.top nav{display:flex;flex-wrap:wrap;gap:.15rem .9rem}
|
|
.top nav a{font:500 .82rem/1 var(--mono);letter-spacing:.06em;color:var(--dim);
|
|
text-decoration:none;padding:.35rem 0;border-bottom:1px solid transparent}
|
|
.top nav a:hover{color:var(--ink)}
|
|
.top nav a[aria-current]{color:var(--trace);border-bottom-color:var(--trace)}
|
|
.who{margin-left:auto;display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;
|
|
font:.78rem/1.3 var(--mono);color:var(--dim)}
|
|
|
|
main{padding:1.1rem;max-width:64rem}
|
|
|
|
/* ---- headings: a graduation mark, like a depth scale ------------------------------- */
|
|
h2{font:500 1.05rem/1.2 var(--mono);letter-spacing:-.01em;margin:1.4rem 0 .2rem;
|
|
padding-left:.7rem;border-left:2px solid var(--trace)}
|
|
h2:first-child{margin-top:0}
|
|
h3{font:500 .8rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
|
|
color:var(--dim);margin:0 0 .7rem}
|
|
.lede{color:var(--dim);font-size:.9rem;margin:.5rem 0 1rem;max-width:46rem}
|
|
|
|
/* ---- readout: how an instrument prints a value ------------------------------------- */
|
|
.readout{list-style:none;margin:0;padding:0}
|
|
.readout li{display:flex;align-items:baseline;gap:.5rem;padding:.3rem 0;
|
|
font:.85rem/1.4 var(--mono)}
|
|
.readout .k{color:var(--dim);white-space:nowrap}
|
|
/* The dotted leader is how a sounding log runs a label out to its value. It is also the thing
|
|
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}
|
|
/* Why a check matters is a sentence, so it is set as one — full width under the row rather
|
|
than squeezed into a column, where it would wrap to a ribbon two words wide. */
|
|
.why{font:.85rem/1.5 var(--prose);color:var(--dim);margin-top:.45rem;max-width:52rem}
|
|
.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}
|
|
:focus-visible{outline:2px solid var(--trace);outline-offset:2px}
|
|
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
|
|
|
/* ---- above 46rem the records line up in columns ------------------------------------ */
|
|
@media (min-width:46rem){
|
|
.top{padding:.85rem 1.6rem}
|
|
main{padding:1.6rem}
|
|
.recs{margin:.8rem 0}
|
|
/* Every row shares one grid, so the columns agree across rows without a header or a table. */
|
|
.rec{display:grid;grid-template-columns:minmax(12.5rem,18rem) 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. */
|
|
.rec .readout{display:grid;grid-template-columns:1.7fr .9fr .9fr 1.1fr;gap:.15rem 1.2rem}
|
|
.rec .readout li{padding:0}
|
|
.rec .readout .lead{display:none}
|
|
.rec .readout .v{text-align:left}
|
|
.open{white-space:nowrap}
|
|
/* Spans the full row: the sentence is the useful part, not a fourth column. */
|
|
.why{grid-column:1/-1;margin-top:.1rem}
|
|
}
|
|
</style></head><body>
|
|
<header class="top">
|
|
<h1 class="mark">echo<span>lot</span></h1>
|
|
{{if ne .Page "login"}}
|
|
<nav>
|
|
<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>
|
|
</span>
|
|
{{end}}
|
|
</header>
|
|
<main>
|
|
|
|
{{if eq .Page "login"}}
|
|
<h2>Sign in</h2>
|
|
<p class="lede">This server keeps the measurements your devices have uploaded.</p>
|
|
{{if .OIDC}}
|
|
<p><a class="btn" href="/auth/start">Sign in with your identity provider</a></p>
|
|
{{end}}
|
|
{{if .LocalSet}}
|
|
<form method="post" action="/login" class="panel narrow">
|
|
<h3>Break-glass account</h3>
|
|
<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>
|
|
</form>
|
|
{{else}}
|
|
<p class="err">No break-glass account is set. Run
|
|
<code>echolot-server --set-admin-password</code> on the host to create one.</p>
|
|
{{end}}
|
|
|
|
{{else if eq .Page "dashboard"}}
|
|
<h2>{{if .Admin}}This server{{else}}Your account{{end}}</h2>
|
|
<ul class="readout panel">
|
|
<li><span class="k">{{if .Admin}}devices enrolled{{else}}your devices{{end}}</span>
|
|
<span class="lead"></span><span class="v">{{.Devices}}</span></li>
|
|
{{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}}
|
|
<div class="panel">
|
|
<p>You can see every device you have signed in on, read everything they have uploaded, and
|
|
delete any of it.</p>
|
|
<p class="muted">Enrolling devices, revoking them, and reading other people's uploads need an
|
|
administrator account.</p>
|
|
</div>
|
|
{{end}}
|
|
{{with .SelfTest}}
|
|
<h2>Self-test</h2>
|
|
<p class="lede">What this server can measure from where it stands, checked at startup. A
|
|
capability missing here is missing from every run this server takes part in — so a
|
|
client asking for that measurement gets nothing, rather than a wrong answer.</p>
|
|
<ul class="readout panel">
|
|
<li><span class="k">kernel settings</span><span class="lead"></span>
|
|
<span class="v {{if .SysctlOK}}v-green{{else}}v-yellow{{end}}">{{if .SysctlOK}}as needed{{else}}need attention{{end}}</span></li>
|
|
<li><span class="k">egress path MTU</span><span class="lead"></span>
|
|
<span class="v {{if .MTUOK}}v-green{{else}}v-yellow{{end}}">{{if .MTUOK}}full 1500{{else}}reduced{{end}}</span></li>
|
|
</ul>
|
|
{{if .Sysctls}}
|
|
<h3>Kernel settings</h3>
|
|
<div class="recs">
|
|
{{range .Sysctls}}
|
|
<div class="rec">
|
|
<div class="rec-head"><span class="id">{{.Name}}</span>
|
|
<span class="tag {{if eq .Severity "ok"}}v-green{{else}}v-yellow{{end}}">{{.Severity}}</span></div>
|
|
<ul class="readout">
|
|
<li><span class="k">found</span><span class="lead"></span><span class="v">{{.Got}}</span></li>
|
|
<li><span class="k">wanted</span><span class="lead"></span><span class="v">{{.Want}}</span></li>
|
|
</ul>
|
|
<div class="why">{{.Why}}</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{if .EgressMTU}}
|
|
<h3>Egress path MTU</h3>
|
|
<div class="recs">
|
|
{{range .EgressMTU}}
|
|
<div class="rec">
|
|
<div class="rec-head"><span class="id">{{.Target}}</span>
|
|
<span class="tag {{if .FullMTU}}v-green{{else}}v-yellow{{end}}">{{if .FullMTU}}full{{else}}reduced{{end}}</span></div>
|
|
<ul class="readout">
|
|
<li><span class="k">discovered</span><span class="lead"></span>
|
|
<span class="v">{{if .DiscoveredMTU}}{{.DiscoveredMTU}} bytes{{else}}not measured{{end}}</span></li>
|
|
</ul>
|
|
{{with .Err}}<div class="why">{{.}}</div>{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
{{else if eq .Page "devices"}}
|
|
<h2>{{if .Admin}}Devices{{else}}Your devices{{end}}</h2>
|
|
{{with .Link}}
|
|
<div class="panel">
|
|
<h3>Enrolment link</h3>
|
|
<p class="lede">Single use, valid 24 hours. Treat it like a password until it is spent.</p>
|
|
<!-- On the phone being enrolled this is the whole procedure: the scheme is registered by the
|
|
app, so following the link hands it the token directly. Copying a 200-character string
|
|
between two devices is the step that goes wrong, and it does not have to happen at all. -->
|
|
{{with $.LinkHref}}<p><a class="btn" href="{{.}}">Open in the Echolot app</a></p>{{end}}
|
|
<p class="muted">Only works on the phone you are enrolling. From anywhere else, copy the link
|
|
into the app's enrolment field, or deliver it over adb.</p>
|
|
<p><code>{{.}}</code></p>
|
|
<p class="muted mono">adb shell am start -a android.intent.action.VIEW -d "{{.}}"</p>
|
|
</div>
|
|
{{end}}
|
|
{{if .Admin}}
|
|
<form method="post" action="/enroll-tokens">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<button>Create enrolment link</button>
|
|
</form>
|
|
{{end}}
|
|
{{if .Rows}}<div class="recs">
|
|
{{range .Rows}}
|
|
<div class="rec">
|
|
<div class="rec-head">
|
|
<span class="id">{{if .Name}}{{.Name}}{{else}}{{.ID}}{{end}}</span>
|
|
{{if .LinkedToAccount}}<span class="tag v-green">{{.AccountName}}</span>
|
|
{{else}}<span class="tag v-unknown">no account</span>{{end}}
|
|
</div>
|
|
<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}}">
|
|
<button class="danger">Revoke</button>
|
|
</form>
|
|
{{end}}
|
|
</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}}
|
|
|
|
{{else if eq .Page "runs"}}
|
|
<h2>{{if .Admin}}Uploaded runs{{else}}Your uploaded runs{{end}}</h2>
|
|
<p class="lede">Each run is shown exactly as it arrived, at the privacy level its uploader chose.
|
|
Nothing here can un-redact one.</p>
|
|
{{if .Rows}}
|
|
<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}}
|
|
<div class="rec">
|
|
<div class="rec-head">
|
|
<span class="id">{{.UploadedAt.Format "2006-01-02 15:04"}}</span>
|
|
<span class="tag {{verdictClass .Verdict}}">{{verdictLabel .Verdict}}</span>
|
|
</div>
|
|
<ul class="readout">
|
|
<li><span class="k">device</span><span class="lead"></span><span class="v">{{.DeviceName}}</span></li>
|
|
<li><span class="k">findings</span><span class="lead"></span><span class="v">{{.FindingCount}}</span></li>
|
|
<li><span class="k">size</span><span class="lead"></span><span class="v">{{kb .SizeBytes}} kB</span></li>
|
|
<li><span class="k">privacy</span><span class="lead"></span><span class="v">{{.Anonymization}}</span></li>
|
|
</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}}
|
|
|
|
{{else if eq .Page "run"}}
|
|
<h2>Run {{.ID}}</h2>
|
|
<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}}">
|
|
<button class="danger">Delete this run</button>
|
|
</form>
|
|
<pre>{{.JSON}}</pre>
|
|
{{end}}
|
|
|
|
</main></body></html>
|
|
`
|