feat(master): Ledger UI for Services and Agents (#18) (#29)
Docker Build / docker (push) Successful in 41s
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:
@@ -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.
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user