feat(master): Ledger UI for Services and Agents (#18) (#29)
Docker Build / docker (push) Successful in 41s

## Summary

- Rebuilds the master's **Services** and **Agents** pages per the "Ledger" design: sidebar shell with live nav counts, machine filter and Live indicator; dark/light theme (follows the OS, toggle overrides); embedded Schibsted Grotesk and JetBrains Mono (fontsource 5.3.0, OFL).
- Services: Swarm grouped per cluster with its machines under it, offline banner ("Agent offline since HH:MM", browser-local), Simple Icons derived from the image ref (letter circle fallback), existing badges and mode switch restyled, client-side search, machine filter and Icon / Icon + text / Text toggle.
- Agents: Online / Offline / Never connected / Revoked, master version in the header with outdated agent versions marked, revoke via popover instead of `confirm()`, "Add agent" as a three-step panel that completes when the agent says hello (SSE `agents`).
- Store: migration `0005_agent_cluster.sql` persists each agent's `swarm_cluster_id` (needed to list a cluster's managers and its offline state after a master restart).
- SSE keepalive is now a named `ping` event so the Live indicator also shows the connection is alive. `revokeAgent` now publishes `agents`.

Closes #18

## Notes

- Workers never report a cluster id (Docker only sets `Swarm.Cluster` on managers), so their Swarm role shows "—". Fixing that needs a new hello field.
- Master binary grows by about 2.16 MB, almost all Simple Icons 16.35.0 path data (gzipped JSON, regenerated with `go generate ./internal/master/web/icons`). The agent binary does not import it.
- Jobs, Job and Service detail pages only get the new shell; their own artboards are out of scope, as are Update buttons, Update all, header stats and the drift badge.
- Unchanged: CSRF on every POST, `Cache-Control: no-store` on the token response, 401 + `HX-Redirect` without a session, no `template.HTML` on agent- or user-controlled values, no Node build step.

## Test plan

- [x] `go test ./...`, `go test -race ./internal/master/...`
- [x] `go test -tags integration ./internal/master/store/` against Postgres 17
- [x] `docker build`
- [x] Browser check on fake data: both themes, 1440 px and 390 px, add-agent flow through connect
- [ ] Services grouping and icons against real stacks and a real Swarm cluster
- [ ] "Agent offline since" after stopping an agent
- [ ] Theme toggle, search and machine filter across reloads and mode changes
- [ ] Add agent end to end with a real agent, Copy buttons
- [ ] Phone at 390 px

https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
2026-10-13 09:20:18 +00:00
parent 919ad91710
commit ec28d1e027
29 changed files with 2140 additions and 199 deletions
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2023 The Schibsted-Grotesk Project Authors (https://github.com/schibsted/schibsted-grotesk) SchibstedGrotesk-Italic[wght].ttf: Copyright 2023 The Schibsted-Grotesk Project Authors (https://github.com/schibsted/schibsted-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+219 -33
View File
@@ -1,34 +1,220 @@
:root { --bg: #111418; --fg: #e6e8eb; --muted: #8a929c; --line: #262b31; --ok: #3fb950; --warn: #d29922; --bad: #f85149; --accent: #58a6ff; }
@media (prefers-color-scheme: light) {
:root { --bg: #ffffff; --fg: #1f2328; --muted: #59636e; --line: #d1d9e0; --ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --accent: #0969da; }
@font-face{font-family:'Schibsted Grotesk';font-style:normal;font-weight:400 900;font-display:swap;src:url(/static/schibsted-grotesk-latin-wght-normal.woff2) format('woff2')}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url(/static/jetbrains-mono-latin-wght-normal.woff2) format('woff2')}
/* Ledger. Tokens live on <html class="app">; a script in <head> adds t-light before first paint. */
.app{color-scheme:dark;--bg:#0f1218;--side:#0b0d12;--card:#151a23;--hover:#171c26;--line:#232a37;--line2:#323a4a;--fg:#e7eaf1;--mut:#a0a9bb;--dim:#828ba0;--blue:#74a8ff;--bluebg:rgba(116,168,255,.12);--orange:#ffa262;--orangebg:rgba(255,162,98,.12);--green:#62d4a0;--red:#ff7d88;--redbg:rgba(255,125,136,.10);--violet:#c5a9ff;--violetbg:rgba(197,169,255,.12);--l0:#74a8ff;--l1:#c5a9ff;--l2:#5fd0e0;--l3:#f29bd0;--onblue:#06142e;--term:#080a0e;--shadow:0 12px 32px rgba(0,0,0,.5);--sans:'Schibsted Grotesk',system-ui,-apple-system,sans-serif;--mono:'JetBrains Mono',ui-monospace,Menlo,monospace;min-height:100vh;background:var(--bg);color:var(--fg);font:14px/1.45 var(--sans)}
.app.t-light{color-scheme:light;--bg:#f7f8fa;--side:#eceff4;--card:#ffffff;--hover:#f0f2f6;--line:#dde1e8;--line2:#c6ccd7;--fg:#121620;--mut:#4b5468;--dim:#5c6579;--blue:#2156c8;--bluebg:rgba(33,86,200,.08);--orange:#a84b0b;--orangebg:rgba(200,96,20,.09);--green:#177649;--red:#b32a3b;--redbg:rgba(179,42,59,.07);--violet:#6943c1;--violetbg:rgba(105,67,193,.08);--l0:#2156c8;--l1:#6943c1;--l2:#0b7285;--l3:#a8306f;--onblue:#ffffff;--term:#121620;--shadow:0 12px 32px rgba(20,30,50,.16)}
*{box-sizing:border-box}
body{margin:0}
[hidden]{display:none !important}
a{color:var(--blue)}a:hover{color:var(--fg)}
button,input{font-family:inherit}
:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
/* Shell */
.shell{display:flex;flex-wrap:wrap;min-height:100vh}
.side{flex:0 0 232px;background:var(--side);border-right:1px solid var(--line);display:flex;flex-direction:column;gap:28px;padding:20px 14px}
.brand{display:flex;align-items:center;gap:10px;font:700 15px var(--sans);letter-spacing:-.01em;padding:0 8px;color:var(--fg);text-decoration:none}
.brand svg{color:var(--blue)}
.snav{display:flex;flex-direction:column;gap:2px}
.snav a,.mlist a{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;color:var(--mut);text-decoration:none;font-weight:500}
.snav a:hover,.mlist a:hover{background:var(--hover);color:var(--fg)}
.snav a.cur,.mlist a.cur{background:var(--card);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line)}
.snav .n,.mlist .n{margin-left:auto;font:12px var(--mono);color:var(--dim)}
.slabel{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 8px 6px}
.mlist{display:flex;flex-direction:column;gap:2px}
.mlist .sub{padding-left:22px;font-size:13px}
.mdot{width:7px;height:7px;border-radius:50%;background:var(--green);flex:none}
.mdot.off{background:transparent;box-shadow:inset 0 0 0 1.5px var(--red)}
.sw-icon{width:7px;height:7px;flex:none;border-radius:2px;box-shadow:inset 0 0 0 1.5px var(--blue)}
.livebox{margin-top:auto;padding:10px 8px;border-top:1px solid var(--line);font-size:12px;color:var(--mut);display:flex;flex-direction:column;gap:2px}
.livebox b{display:flex;align-items:center;gap:8px;color:var(--fg);font-weight:600}
.pulse{width:8px;height:8px;border-radius:50%;background:var(--green);animation:breathe 2.4s ease-in-out infinite}
.livebox.down .pulse{background:var(--red);animation:none}
@keyframes breathe{0%{box-shadow:0 0 0 0 rgba(98,212,160,.45)}50%{box-shadow:0 0 0 5px rgba(98,212,160,0)}}
.sfoot{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px}
.sfoot form{margin:0}
.work{flex:999 1 640px;min-width:0;display:flex;flex-wrap:wrap}
.main{flex:999 1 520px;min-width:0;padding:28px 36px 48px;container-type:inline-size}
.sprite{position:absolute;width:0;height:0;overflow:hidden}
/* Page header, buttons */
.hdr{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 24px;margin-bottom:18px}
.hdr h1{margin:0;font:700 34px/1 var(--sans);letter-spacing:-.025em}
.hdr .sub{font-size:14px;color:var(--mut)}
.hdr .end{margin-left:auto}
.btn,button:not([class]){height:32px;padding:0 14px;border-radius:6px;border:1px solid var(--line2);background:var(--card);color:var(--fg);font:600 13px var(--sans);cursor:pointer;display:inline-flex;align-items:center;gap:8px;white-space:nowrap;text-decoration:none}
.btn:hover,button:not([class]):hover{border-color:var(--dim);color:var(--fg)}
button:not([class]):disabled{opacity:.5;cursor:not-allowed}
.btn.pri,.btn.pri:hover{background:var(--blue);border-color:var(--blue);color:var(--onblue)}
.btn.sm{height:28px;padding:0 10px;font-size:12.5px}
.btn.quiet{border-color:transparent;background:transparent;color:var(--mut)}
.btn.quiet:hover{color:var(--fg)}
.btn.red{color:var(--red);border-color:var(--line2);background:transparent}
.btn.redfill,.btn.redfill:hover{background:var(--red);border-color:var(--red);color:#1d0408}
summary.btn{list-style:none}
summary.btn::-webkit-details-marker{display:none}
/* Overview */
.tools{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;padding:12px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:8px}
.search{flex:0 1 340px;min-width:220px;display:flex;align-items:center;gap:8px;height:34px;padding:0 12px;border-radius:6px;background:var(--card);border:1px solid var(--line2);color:var(--dim)}
.search input{flex:1;min-width:0;border:0;outline:0;background:transparent;color:var(--fg);font-size:14px}
.search input::placeholder{color:var(--dim)}
.tools .end{margin-left:auto;display:flex;align-items:center;gap:10px}
.tools .lbl{font-size:12.5px;color:var(--dim)}
.seg{display:inline-flex;padding:2px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2)}
.seg button{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:0;border-radius:5px;background:transparent;color:var(--mut);font:500 12.5px var(--sans);cursor:pointer}
.seg button:hover{color:var(--fg)}
.seg button[aria-pressed=true]{background:var(--hover);color:var(--fg);box-shadow:inset 0 0 0 1px var(--line2)}
.grp{margin-top:28px}
.gh{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;padding-bottom:8px;border-bottom:2px solid var(--fg)}
.gh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
.gh .kind{font:600 11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--mut)}
.gh .meta{font-size:13px;color:var(--dim)}
.gh .warn{font-size:13px;color:var(--red);font-weight:500}
.gh .count{margin-left:auto;font:12px var(--mono);color:var(--dim)}
.grp.offline .gh{border-bottom-color:var(--line2)}
.grp.offline .row{color:var(--dim)}
.row{position:relative;display:grid;grid-template-columns:minmax(200px,1.4fr) minmax(200px,2fr) 130px minmax(160px,1.5fr) minmax(104px,150px);align-items:center;column-gap:18px;min-height:42px;padding:0 6px;border-bottom:1px solid var(--line)}
.row:hover{background:var(--hover)}
.nm{display:flex;align-items:center;gap:8px;min-width:0}
.nm a{display:inline-flex;align-items:center;gap:10px;min-width:0;color:var(--fg);font-weight:600;text-decoration:none;white-space:nowrap}
.nm a:hover .nmt{text-decoration:underline;text-underline-offset:3px}
.nm .nmt{overflow:hidden;text-overflow:ellipsis}
.nm .stk{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ico{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:7px;background:var(--card);box-shadow:inset 0 0 0 1px var(--line2);color:var(--fg)}
.ico svg{fill:currentColor}
.ltr{flex:none;display:grid;place-items:center;width:26px;height:26px;border-radius:50%;font:700 12.5px var(--sans)}
.lc-0{color:var(--l0);background:color-mix(in srgb,var(--l0) 18%,transparent)}
.lc-1{color:var(--l1);background:color-mix(in srgb,var(--l1) 18%,transparent)}
.lc-2{color:var(--l2);background:color-mix(in srgb,var(--l2) 18%,transparent)}
.lc-3{color:var(--l3);background:color-mix(in srgb,var(--l3) 18%,transparent)}
.grp.offline .ico,.grp.offline .ltr{opacity:.65}
.d-icon .nmt,.d-icon .stk{display:none}
.d-text .ico,.d-text .ltr{display:none}
.ver{display:flex;min-width:0;font:12.5px var(--mono);color:var(--dim);white-space:nowrap}
.ver .r{overflow:hidden;text-overflow:ellipsis;min-width:0}
.ver .t{flex:none}
.ver b{color:var(--fg);font-weight:500}
.st{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:500;white-space:nowrap}
.st svg{flex:none}
.st circle{fill:currentColor}
.s-running{color:var(--green)}.s-degraded{color:var(--orange)}.s-stopped{color:var(--dim)}.s-unknown{color:var(--dim)}
.s-stopped circle,.s-unknown circle{fill:none}
.flags{display:flex;flex-wrap:wrap;gap:6px}
.tag{font:500 12px/20px var(--sans);padding:0 8px;border-radius:999px;white-space:nowrap}
.tag em{font:500 11.5px var(--mono);font-style:normal;margin-left:6px}
.tag em:empty{display:none}
.f-up{color:var(--orange);background:var(--orangebg)}
.f-err{color:var(--red);background:var(--redbg)}
.f-notag{color:var(--mut);box-shadow:inset 0 0 0 1px var(--line2)}
.f-out{color:var(--dim);box-shadow:inset 0 0 0 1px var(--line)}
.mode{display:flex;flex-direction:column;align-items:flex-start;gap:2px}
.mode form{margin:0;display:flex;flex-direction:column;align-items:flex-start;gap:2px}
.sw{display:inline-flex;align-items:center;gap:8px;padding:0;border:0;background:none;font:inherit;font-size:13px;color:var(--mut);cursor:pointer}
.sw i{flex:none;width:30px;height:18px;border-radius:999px;background:var(--line2);position:relative;transition:background .15s}
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--fg);transition:transform .15s}
.sw[aria-checked=true] i{background:var(--blue)}
.sw[aria-checked=true] i::after{transform:translateX(12px);background:var(--onblue)}
.sw[aria-checked=true]{color:var(--blue);font-weight:600}
.sw:disabled{opacity:.55;cursor:not-allowed}
.na{font-size:12.5px;color:var(--dim)}
.nomatch{padding:24px 6px;color:var(--dim)}
/* Agents */
.hdr.ruled{padding-bottom:16px;border-bottom:2px solid var(--fg);margin-bottom:0}
.agent{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) 130px 90px 110px 120px 84px;grid-template-areas:"id st ver mgr last act";align-items:center;column-gap:18px;padding:16px 6px;border-bottom:1px solid var(--line)}
.agent .id{grid-area:id}
.agent .id b{display:block;font:700 16px var(--sans)}
.agent .id span{font:12.5px var(--mono);color:var(--dim)}
.agent .stc{grid-area:st}.agent .ver{grid-area:ver;display:block;font:13px var(--mono);color:var(--mut)}.agent .mgr{grid-area:mgr;font-size:13px;color:var(--mut)}.agent .last{grid-area:last;font-size:13px;color:var(--mut)}.agent .act{grid-area:act;display:flex;justify-content:flex-end}
.agent .k{display:block;font:600 10.5px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin-bottom:2px}
.agent .ver.old{color:var(--orange)}
.agent .st{font-size:13.5px;font-weight:600}
.agent .st i{width:8px;height:8px;border-radius:50%;background:currentColor}
.a-on{color:var(--green)}.a-off{color:var(--red)}.a-never,.a-revoked{color:var(--dim)}
.a-off i,.a-never i{background:transparent !important;box-shadow:inset 0 0 0 1.5px currentColor}
/* After the .agent rules: a container query adds no specificity, so it must come later to win. */
@container (max-width:760px){
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
}
.pop{position:absolute;right:6px;top:calc(100% - 8px);z-index:5;width:360px;padding:14px 16px;border-radius:8px;background:var(--card);border:1px solid var(--line2);box-shadow:var(--shadow);font-size:13px;color:var(--mut)}
.pop::before{content:"";position:absolute;top:-6px;right:30px;width:10px;height:10px;background:var(--card);border-left:1px solid var(--line2);border-top:1px solid var(--line2);transform:rotate(45deg)}
.pop h4{margin:0 0 4px;font:700 14px var(--sans);color:var(--fg)}
.pop p{margin:0 0 12px}
.pop .acts{display:flex;gap:8px;justify-content:flex-end;margin:0}
.drawer{flex:1 1 440px;min-width:0;max-width:520px;border-left:1px solid var(--line2);background:var(--card);padding:28px 28px 40px;display:flex;flex-direction:column;gap:18px}
.dh{display:flex;align-items:center;gap:12px}
.dh h2{margin:0;font:700 20px var(--sans);letter-spacing:-.015em}
.dh .x{margin-left:auto}
.steps{display:flex;gap:8px;font-size:12.5px;color:var(--dim)}
.steps span{display:flex;align-items:center;gap:6px}
.steps b{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:50%;font:600 11px var(--mono);box-shadow:inset 0 0 0 1px var(--line2)}
.steps .done{color:var(--green)}.steps .done b{box-shadow:none;background:var(--green);color:var(--bg)}
.steps .now{color:var(--fg)}.steps .now b{box-shadow:none;background:var(--blue);color:var(--onblue)}
.steps .sep{width:16px;height:1px;background:var(--line2)}
.dform{display:flex;flex-direction:column;gap:18px;margin:0}
.field{display:flex;flex-direction:column;gap:6px;font-size:13.5px;color:var(--mut)}
.field input{height:36px;padding:0 12px;border-radius:6px;border:1px solid var(--line2);background:var(--bg);color:var(--fg);font:500 14px var(--mono)}
.field small{color:var(--dim);font-size:12.5px}
.namebox{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-radius:6px;background:var(--bg);font-size:13.5px}
.namebox code{font:500 14px var(--mono);color:var(--fg)}
.notice{display:flex;gap:10px;padding:12px 14px;border-radius:6px;background:var(--orangebg);color:var(--fg);font-size:13.5px}
.notice svg{flex:none;color:var(--orange);margin-top:1px}
.code{border-radius:6px;background:var(--term);overflow:hidden}
.code-h{display:flex;align-items:center;justify-content:space-between;padding:6px 8px 6px 12px;border-bottom:1px solid #1d2230;font:12px var(--mono);color:#8d96a8}
.code pre{margin:0;padding:12px;border:0;color:#cdd3df;font:12px/1.6 var(--mono);overflow-x:auto}
.secret{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:8px 12px;font-size:13px;color:var(--mut)}
.secret code{font:12.5px var(--mono);color:var(--fg);overflow-wrap:anywhere}
.df{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:auto}
.df form{margin:0}
.df > span{margin-right:auto;font-size:13px;color:var(--mut);display:flex;align-items:center;gap:8px}
.df .ok{color:var(--green)}
.spin{width:12px;height:12px;border-radius:50%;box-shadow:inset 0 0 0 2px var(--line2);border-top:2px solid var(--blue);box-sizing:border-box;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Pages that keep the plain markup: service, jobs, job */
h1{font:700 34px/1.1 var(--sans);letter-spacing:-.025em;margin:0 0 18px}
h2{font:700 20px var(--sans);letter-spacing:-.015em;margin:28px 0 8px}
table{width:100%;border-collapse:collapse;margin-bottom:24px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
th{color:var(--mut);font-weight:500}
code,pre{font:13px var(--mono)}
pre{padding:12px;border:1px solid var(--line);border-radius:6px;overflow-x:auto}
.badge{font-size:12px;color:var(--mut);border:1px solid var(--line);padding:0 6px;border-radius:8px}
.status-running{color:var(--green)}
.status-degraded,.status-unknown{color:var(--orange)}
.status-stopped{color:var(--red)}
.error{color:var(--red)}
.warning{color:var(--orange)}
.muted{color:var(--dim)}
.status-job-succeeded{color:var(--green)}
.status-job-queued,.status-job-running{color:var(--orange)}
.status-job-failed{color:var(--red)}
table.details{width:auto}
table.details th{padding-right:24px}
pre.log{white-space:pre-wrap}
@media (max-width:760px){
.side{flex:1 1 100%;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px 16px;padding:12px 16px;border-right:0;border-bottom:1px solid var(--line)}
#snav{order:3;width:100%}
.snav{flex-direction:row;overflow-x:auto}
.snav a{padding:10px 12px}
.snav .n{margin-left:4px}
.mblock{display:none}
.livebox{margin:0 0 0 auto;border:0;padding:0}
.livebox #live-age{display:none}
.main{padding:20px 16px 40px}
.hdr h1{font-size:28px}
.hdr .end,.tools .end{margin-left:0}
.search{flex:1 1 100%}
.btn,button:not([class]){height:44px}
.btn.sm{height:36px}
.row{grid-template-columns:1fr auto;grid-template-areas:"nm st" "ver ver" "fl fl" "mode mode";row-gap:8px;padding:12px 4px}
.nm{grid-area:nm}.stc{grid-area:st}.row .ver{grid-area:ver}.flags{grid-area:fl}.mode{grid-area:mode}
.flags:empty{display:none}
.agent{grid-template-columns:1fr auto;grid-template-areas:"id st" "ver mgr" "last act";row-gap:10px}
.drawer{order:-1;max-width:none;border-left:0;border-bottom:1px solid var(--line2);padding:20px 16px}
.pop{left:0;right:0;width:auto}
table{display:block;overflow-x:auto}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
nav { display: flex; gap: 16px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
nav form { margin-left: auto; }
main { padding: 16px; max-width: 1200px; margin: 0 auto; }
a { color: var(--accent); }
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 500; }
code, pre { font: 13px ui-monospace, monospace; }
pre { padding: 12px; border: 1px solid var(--line); overflow-x: auto; }
.badge { font-size: 12px; color: var(--muted); border: 1px solid var(--line); padding: 0 6px; border-radius: 8px; }
.status-running { color: var(--ok); }
.status-degraded, .status-unknown { color: var(--warn); }
.status-stopped { color: var(--bad); }
.error { color: var(--bad); }
.warning { color: var(--warn); }
form.inline { display: flex; gap: 8px; margin-bottom: 16px; }
@media (max-width: 600px) { table { display: block; overflow-x: auto; } }
.muted { color: var(--muted); }
.status-job-succeeded { color: var(--ok); }
.status-job-queued, .status-job-running { color: var(--warn); }
.status-job-failed { color: var(--bad); }
table.details { width: auto; }
table.details th { padding-right: 24px; }
pre.log { white-space: pre-wrap; }
.badge-update { color: var(--warn); border-color: var(--warn); }
.badge-error { color: var(--bad); border-color: var(--bad); }
form.mode { display: inline; margin-left: 8px; }
form.mode button { font-size: 12px; padding: 0 6px; }
+226
View File
@@ -0,0 +1,226 @@
// Behaviour the server-rendered pages need in the browser: theme, display
// mode, search and machine filter, the Live box, local times, copy buttons and
// the revoke popovers. The pages work without it; htmx and the SSE extension
// do the live updates. Everything stored in the browser is wrapped in
// try/catch because storage can be blocked.
(function () {
'use strict';
var root = document.documentElement;
function load(area, key) {
try { return window[area].getItem(key); } catch (e) { return null; }
}
function save(area, key, value) {
try { window[area].setItem(key, value); } catch (e) { /* storage is blocked */ }
}
function each(selector, fn) {
Array.prototype.forEach.call(document.querySelectorAll(selector), fn);
}
// Theme: the head script set the initial class; the toggle overrides it.
var colorScheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
var themeButton = document.getElementById('theme-toggle');
if (themeButton) {
themeButton.addEventListener('click', function () {
var light = !root.classList.contains('t-light');
root.classList.toggle('t-light', light);
themeButton.setAttribute('aria-pressed', String(light));
save('localStorage', 'pu-theme', light ? 'light' : 'dark');
});
themeButton.setAttribute('aria-pressed', String(root.classList.contains('t-light')));
}
if (colorScheme && colorScheme.addEventListener) {
colorScheme.addEventListener('change', function (e) {
if (!load('localStorage', 'pu-theme')) root.classList.toggle('t-light', e.matches);
});
}
// Display mode: icon, icon + text, or text.
function setDisplay(mode) {
['icon', 'both', 'text'].forEach(function (m) { root.classList.toggle('d-' + m, m === mode); });
each('[data-display]', function (b) { b.setAttribute('aria-pressed', String(b.dataset.display === mode)); });
}
setDisplay(root.classList.contains('d-icon') ? 'icon' : root.classList.contains('d-text') ? 'text' : 'both');
// Search and machine filter. The rows are all in the page; this hides them.
var filter = { q: '', machine: '' };
try {
var saved = JSON.parse(load('sessionStorage', 'pu-filter') || '{}');
if (typeof saved.q === 'string') filter.q = saved.q;
if (typeof saved.machine === 'string') filter.machine = saved.machine;
} catch (e) { /* ignore a broken saved filter */ }
var search = document.getElementById('q');
if (search) search.value = filter.q;
// The machine list is hidden on narrow screens; a filter that cannot be
// seen or cleared there is ignored. Search still applies.
var narrow = window.matchMedia ? window.matchMedia('(max-width:760px)') : null;
function applyFilter() {
var box = document.getElementById('services');
if (!box) return;
var groups = box.querySelectorAll('.grp');
var known = Array.prototype.some.call(groups, function (g) { return g.dataset.key === filter.machine; });
if (filter.machine && !known) filter.machine = '';
var machine = narrow && narrow.matches ? '' : filter.machine;
var q = filter.q.trim().toLowerCase();
var any = false;
Array.prototype.forEach.call(groups, function (g) {
var inMachine = !machine || g.dataset.key === machine;
var shown = 0;
Array.prototype.forEach.call(g.querySelectorAll('.row'), function (row) {
var match = inMachine && (!q || (row.dataset.search || '').indexOf(q) !== -1);
row.hidden = !match;
if (match) shown++;
});
g.hidden = shown === 0;
if (shown > 0) any = true;
});
var none = document.getElementById('nomatch');
if (none) none.hidden = any || groups.length === 0;
each('#machines a', function (a) {
var cur = a.dataset.machine === filter.machine;
a.classList.toggle('cur', cur);
if (cur) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current');
});
}
if (narrow && narrow.addEventListener) narrow.addEventListener('change', function () { applyFilter(); });
function saveFilter() { save('sessionStorage', 'pu-filter', JSON.stringify(filter)); }
if (search) {
search.addEventListener('input', function () { filter.q = search.value; saveFilter(); applyFilter(); });
}
// Live box: counts any event on the stream, including the server's ping.
var live = document.getElementById('live');
var liveAge = document.getElementById('live-age');
var lastEvent = null;
var down = false;
var wired = typeof WeakSet === 'function' ? new WeakSet() : null;
function touch() { lastEvent = Date.now(); }
document.addEventListener('htmx:sseOpen', function (e) {
var source = e.detail && e.detail.source;
touch();
down = false;
if (source && wired && !wired.has(source)) {
wired.add(source);
['ping', 'services', 'agents', 'jobs'].forEach(function (name) { source.addEventListener(name, touch); });
}
});
document.addEventListener('htmx:sseError', function () { down = true; });
function renderLive() {
if (!live || !liveAge) return;
live.classList.toggle('down', down);
if (down) { liveAge.textContent = 'Reconnecting…'; return; }
if (lastEvent === null) { liveAge.textContent = 'connecting…'; return; }
var s = Math.max(0, Math.round((Date.now() - lastEvent) / 1000));
liveAge.textContent = 'last event ' + (s < 60 ? s + ' s ago' : Math.floor(s / 60) + ' min ago');
}
// Times: the server writes <time datetime="..."> with its own clock as
// fallback text; show them in the browser's time zone.
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function pad(n) { return (n < 10 ? '0' : '') + n; }
function localiseTimes() {
each('time[datetime]', function (t) {
var d = new Date(t.getAttribute('datetime'));
if (isNaN(d.getTime())) return;
var text;
if (t.hasAttribute('data-rel')) {
var min = Math.floor((Date.now() - d.getTime()) / 60000);
text = min < 1 ? 'just now' : min < 60 ? min + ' min ago' : min < 1440 ? Math.floor(min / 60) + ' h ago' : Math.floor(min / 1440) + ' d ago';
t.title = d.toLocaleString();
} else {
var clock = pad(d.getHours()) + ':' + pad(d.getMinutes());
text = d.toDateString() === new Date().toDateString() ? clock : months[d.getMonth()] + ' ' + d.getDate() + ' ' + clock;
}
if (t.textContent !== text) t.textContent = text;
});
}
// Copy buttons: data-copy holds the text, data-copy-target the id of the element to copy.
function flash(button, text) {
if (!button.dataset.label) button.dataset.label = button.textContent;
button.textContent = text;
setTimeout(function () { button.textContent = button.dataset.label; }, 2000);
}
function copyFallback(button, text) {
var target = button.dataset.copyTarget ? document.getElementById(button.dataset.copyTarget) : null;
if (target) {
var range = document.createRange();
range.selectNodeContents(target);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
flash(button, 'Press Ctrl+C');
return;
}
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { /* not allowed */ }
document.body.removeChild(area);
flash(button, ok ? 'Copied' : 'Copy failed');
}
// Revoke popovers are <details>. A swap of the agent list would close them, so reopen those that were open.
var openRevokes = [];
document.addEventListener('click', function (e) {
var display = e.target.closest('[data-display]');
if (display) {
setDisplay(display.dataset.display);
save('localStorage', 'pu-display', display.dataset.display);
return;
}
var machine = e.target.closest('#machines a');
if (machine) {
e.preventDefault();
filter.machine = filter.machine === machine.dataset.machine ? '' : machine.dataset.machine;
saveFilter();
applyFilter();
return;
}
var close = e.target.closest('[data-close]');
if (close) {
var details = close.closest('details');
if (details) details.open = false;
return;
}
var copy = e.target.closest('[data-copy], [data-copy-target]');
if (copy) {
var text = copy.dataset.copy;
if (text === undefined) {
var el = document.getElementById(copy.dataset.copyTarget);
text = el ? el.textContent : '';
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () { flash(copy, 'Copied'); }, function () { copyFallback(copy, text); });
} else {
copyFallback(copy, text);
}
}
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') each('details.rv[open]', function (d) { d.open = false; });
});
document.addEventListener('htmx:beforeSwap', function () {
openRevokes = [];
each('details.rv[open]', function (d) { openRevokes.push(d.dataset.id); });
});
document.addEventListener('htmx:afterSwap', function () {
each('details.rv', function (d) { if (openRevokes.indexOf(d.dataset.id) !== -1) d.open = true; });
});
document.addEventListener('htmx:afterSettle', function () { applyFilter(); localiseTimes(); });
applyFilter();
localiseTimes();
renderLive();
setInterval(renderLive, 1000);
setInterval(localiseTimes, 15000);
})();