authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 20:13:12-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-04 20:25:21-07:00
log0c8b295ae9b40d73e879d1da1b961a6ad5f7c616
treeecfa3f45e3c5b952ae3f7853e2b16fbee8d55ac7
parent8cd94ab463368b2e16137ffb0d4699104701dc8b
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Show relevant apps and current failures on Snow Globe

Keep backend services out of launchers and Dawarich visible only to admins. Use full navigation for sign-out and wait for the profile name in the sidebar. Fix Shale navigation contrast, recent timestamps, inline code, and narrow commit views. Assisted-by: gpt-6.1-sol

13 files changed, 90 insertions(+), 68 deletions(-)

config/Service.pkl+5
......@@ -8,6 +8,9 @@ const nomadHostPort = "{{ if regexMatch \":\" .Address }}[{{ .Address }}]{{ else
88class Metadata {
99 name: String
1010 tagline: String = ""
11 launcher: Boolean = true
12 /// Discovery group; absent uses the public route's auth role.
13 access: String?
1114}
1215
1316/// One HTTP listener and its public route.
......@@ -156,6 +159,8 @@ output {
156159 id = module.id
157160 name = module.meta.name
158161 tagline = module.meta.tagline
162 launcher = module.meta.launcher
163 access = module.meta.access
159164 rollout = module.rollout
160165 stageIsolation = module.stageIsolation
161166 healthyDeadline = vmStartupGrace ?? module.healthyDeadline
dashboard/src/core.rs+12-14
......@@ -350,7 +350,7 @@ async fn summarize(app: Arc<App>, id: &str, found: &Value) -> Value {
350350 }
351351 })
352352 .sum();
353 json!({"id":id,"name":meta["studio_name"].as_str().unwrap_or(id),"health":found["health"].as_str().unwrap_or("down"),"url":meta["studio_hostname"].as_str().filter(|h| !h.is_empty()).map(|h| format!("https://{h}")),"icon":icon_of(app.clone(),id).await,"access":meta["studio_auth_role"],"tagline":meta["studio_tagline"],"cpu":usage["cpu"],"cpuLimit":if mhz > 0.0 { cpu/mhz } else {0.0},"memory":usage["memory"],"memoryLimit":memory*1048576.0})
353 json!({"id":id,"name":meta["studio_name"].as_str().unwrap_or(id),"health":found["health"].as_str().unwrap_or("down"),"url":meta["studio_hostname"].as_str().filter(|h| !h.is_empty() && meta["studio_launcher"] != "false").map(|h| format!("https://{h}")),"icon":icon_of(app.clone(),id).await,"access":meta["studio_auth_role"],"tagline":meta["studio_tagline"],"cpu":usage["cpu"],"cpuLimit":if mhz > 0.0 { cpu/mhz } else {0.0},"memory":usage["memory"],"memoryLimit":memory*1048576.0})
354354}
355355async fn summaries(app: Arc<App>) -> Result<Arc<Document>> {
356356 let state = app.clone();
......@@ -432,21 +432,13 @@ async fn issues(app: Arc<App>) -> Result<Value> {
432432 .cache
433433 .peek("nomad-scan", Duration::from_secs(10))
434434 .ok_or_else(|| Error::new(503, "Service status is unavailable."))?;
435 let ack = read_json(&app.data.join("restarts-acknowledged.json"), json!({})).await?;
436435 let mut issues = Vec::new();
437436 for service in array(&summaries.value) {
438437 let id = string(&service["id"]);
439438 let health = string(&service["health"]);
440439 let found = &jobs.value[id];
441 let cs = containers(found);
442 let restart = array(&cs)
443 .iter()
444 .map(|c| &c["lastRestart"])
445 .filter(|r| !r.is_null() && (ack[id].is_null() || number(&r["t"]) > number(&ack[id])))
446 .max_by(|a, b| number(&a["t"]).total_cmp(&number(&b["t"])));
447 let trouble = health == "down" || health == "degraded";
448 if trouble || restart.is_some() {
449 issues.push(json!({"service":{"id":id,"name":service["name"],"icon":service["icon"],"url":service["url"]},"health":health,"failing":if trouble {array(&found["allocs"]).iter().flat_map(|a| array(&a["home_checks"])).find(|c| c["Status"] == "failure").map(|c| c["Output"].clone())} else {None},"restart":restart}));
440 if matches!(health, "down" | "degraded") {
441 issues.push(json!({"service":{"id":id,"name":service["name"],"icon":service["icon"],"url":service["url"]},"health":health,"failing":array(&found["allocs"]).iter().flat_map(|a| array(&a["home_checks"])).find(|c| c["Status"] == "failure").map(|c| c["Output"].clone())}));
450442 }
451443 }
452444 Ok(json!(issues))
......@@ -493,9 +485,9 @@ async fn launcher_module(repo: &Path) -> Result<String> {
493485 }
494486 module.push_str(&format!("local services = Map({})\n", services.join(", ")));
495487 module.push_str(r#"output { renderer = new JsonRenderer { omitNullProperties = false }
496value = services.filter((_, s) -> s.enabled).mapValues((_, s) ->
488value = services.filter((_, s) -> s.enabled && s.meta.launcher).mapValues((_, s) ->
497489let (route = (s.containers?.toMap()?.values ?? List()).add(s.container).filterNonNull().map((task) -> task.http).filterNonNull().findOrNull((http) -> http.hostname != null))
498new Dynamic { name = s.meta.name; tagline = s.meta.tagline; hostname = route?.hostname; access = route?.authRole }) }"#);
490new Dynamic { name = s.meta.name; tagline = s.meta.tagline; hostname = route?.hostname; access = s.meta.access ?? route?.authRole }) }"#);
499491 Ok(module)
500492}
501493
......@@ -690,7 +682,13 @@ pub async fn route(
690682 && can_open(&groups, a["access"].as_str())
691683 })
692684 })
693 .cloned()
685 .map(|issue| {
686 let mut issue = issue.clone();
687 if !admin {
688 issue["failing"] = Value::Null;
689 }
690 issue
691 })
694692 .collect::<Vec<_>>()
695693 });
696694 let sample = host::sample(app).await?;
dashboard/web/components/Sidebar.tsx+7-5
......@@ -15,7 +15,7 @@ import UserRound from "lucide-solid/icons/user-round";
1515import Plug from "lucide-solid/icons/plug";
1616import Users from "lucide-solid/icons/users";
1717import { createSignal, For, type JSX, Show } from "solid-js";
18import { type Health, type Me, type Section, trouble, VIEW_AS } from "../types/model.ts";
18import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts";
1919import { queries } from "../api.ts";
2020import snowflake from "../snowflake.svg";
2121import { bytes, cores, plural } from "../format.ts";
......@@ -75,7 +75,7 @@ function IssueCount() {
7575 <Show when={issues().length}>
7676 <span class="badge">
7777 <span class="tally" data-tip={issues().map((issue) =>
78 `${issue.service.name} ${trouble(issue.health) ? issue.health : "restarted"}`).join(", ")}>
78 `${issue.service.name} ${issue.health}`).join(", ")}>
7979 <StatusLabel health={issues().some((issue) => issue.health === "down") ? "down" : "degraded"}>{issues().length}</StatusLabel>
8080 <span class="sr-only">need a look</span>
8181 </span>
......@@ -223,7 +223,7 @@ export function viewAs(groups: string[] | null) {
223223 location.reload();
224224}
225225
226/** The signed-in user's corner; it falls back to the forwarded name while Keycloak is out of reach. */
226/** The signed-in user's corner. */
227227function Whoami(props: { me: Me }) {
228228 const user = lastGood(account);
229229 const name = () => {
......@@ -237,12 +237,14 @@ function Whoami(props: { me: Me }) {
237237 <div class="whoami">
238238 <button class="whoami-button" classList={{ active: !!here() }} popovertarget="account-menu">
239239 <Avatar picture={user()?.picture ?? null} name={name()} />
240 <span class="name">{name()}</span>
240 <span class="name"><Show when={user()} fallback={account.error ? props.me.name : <span class="skeleton" style={{ width: "100px" }} />}>
241 {(profile) => displayName(profile())}
242 </Show></span>
241243 <ChevronsUpDown size={14} class="icon" />
242244 </button>
243245 <div ref={menu} id="account-menu" popover class="account-menu">
244246 <A href="/account" class="nav-item" onClick={() => menu.hidePopover()}><UserRound class="icon" /><span class="label">profile</span></A>
245 <a href="/api/account/sign-out" class="nav-item"><LogOut class="icon" /><span class="label">sign out</span></a>
247 <a href="/api/account/sign-out" rel="external" class="nav-item"><LogOut class="icon" /><span class="label">sign out</span></a>
246248 <Show when={props.me.viewing || props.me.sections.includes("admin")}>
247249 <form class="view-as" onSubmit={(event) => {
248250 event.preventDefault();
dashboard/web/pages/Overview.css-2
......@@ -81,9 +81,7 @@
8181.changes .muted a { color: var(--text-2); text-decoration: underline dotted var(--axis); text-underline-offset: 3px; }
8282.changes a[href]:hover { color: var(--accent); text-decoration-color: currentColor; }
8383.changes.issues { border-bottom: 1px solid var(--grid); }
84.issues li:has(> button) { grid-template-columns: 14px 1fr auto auto; }
8584.issues li > .status { justify-self: center; }
86.issues .button.icon-only { --control: 22px; width: 22px; }
8785.changes time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
8886
8987.section-head { display: flex; align-items: center; gap: 12px; margin: 22px 0 6px; }
dashboard/web/pages/Overview.tsx+8-37
......@@ -5,11 +5,9 @@ import BatteryWarning from "lucide-solid/icons/battery-warning";
55import HardDrive from "lucide-solid/icons/hard-drive";
66import Power from "lucide-solid/icons/power";
77import Rocket from "lucide-solid/icons/rocket";
8import RotateCcw from "lucide-solid/icons/rotate-ccw";
98import ShieldCheck from "lucide-solid/icons/shield-check";
109import Trash from "lucide-solid/icons/trash";
1110import Undo2 from "lucide-solid/icons/undo-2";
12import X from "lucide-solid/icons/x";
1311import { createMemo, createResource, createRoot, createSignal, For, type JSX, onCleanup, Show } from "solid-js";
1412import { type Health, type HostInfo, type Live, type Me, type Series, type ServiceSummary, trouble } from "../types/model.ts";
1513import { api, queries, reason } from "../api.ts";
......@@ -69,46 +67,19 @@ function Greeting(props: { me: Me }) {
6967 );
7068}
7169
72/** Apps that are down or degraded or restarted unacknowledged; admins reach each service and clear its restart. */
7370function Issues(props: { admin: boolean }) {
74 const clear = (id: string) => parseResponse(api.services[":id"].restarts.acknowledge.$post({ param: { id } }))
75 .then(status.refetch, (failure) => toast(`Couldn't clear the restart. ${reason(failure)}`));
7671 return (
7772 <Show when={status.latest()?.issues?.length}>
7873 <ul class="changes issues" aria-label="Needs a look">
7974 <For each={status.latest()!.issues!}>
80 {(issue) => {
81 const [clearing, setClearing] = createSignal(false);
82 const name = () => props.admin ? <a href={`/services/${issue.service.id}`}>{issue.service.name}</a> : issue.service.name;
83 return (
84 <>
85 <Show when={trouble(issue.health)}>
86 <li class="warn">
87 <Status health={issue.health} />
88 <span class="what">{name()} {issue.health} <span class="muted">{issue.failing}</span></span>
89 </li>
90 </Show>
91 <Show when={issue.restart}>
92 {(restart) => (
93 <li class="warn">
94 <RotateCcw size={14} aria-hidden="true" />
95 <span class="what">{name()} restarted <span class="muted">{restart().reason}</span></span>
96 <Ago t={restart().t} />
97 <Show when={props.admin}>
98 <button class="button icon-only" aria-label={`Clear ${issue.service.name}'s restart`} data-tip="clear"
99 disabled={clearing()} aria-busy={clearing()} onClick={() => {
100 setClearing(true);
101 clear(issue.service.id).finally(() => setClearing(false));
102 }}>
103 <Show when={!clearing()}><X size={12} /></Show>
104 </button>
105 </Show>
106 </li>
107 )}
108 </Show>
109 </>
110 );
111 }}
75 {(issue) => (
76 <li class="warn">
77 <Status health={issue.health} />
78 <span class="what"><Show when={props.admin} fallback={issue.service.name}>
79 <a href={`/services/${issue.service.id}`}>{issue.service.name}</a>
80 </Show> {issue.health} <span class="muted">{issue.failing}</span></span>
81 </li>
82 )}
11283 </For>
11384 </ul>
11485 </Show>
dashboard/web/types/model.ts+1-2
......@@ -53,13 +53,12 @@ export interface ServiceSummary {
5353/** States that need someone to look, unlike stopped (on purpose) or the passing ones like restarting. */
5454export const trouble = (health: Health) => health === "down" || health === "degraded";
5555
56/** A service that needs a look: down or degraded, or restarted since restarts were last acknowledged. */
56/** A service that is currently down or degraded. */
5757export interface Issue {
5858 service: Pick<ServiceSummary, "id" | "name" | "icon" | "url">;
5959 health: Health;
6060 /** The failing check's output while it's down or degraded. */
6161 failing: string | null;
62 restart: { t: number; reason: string } | null;
6362}
6463
6564/** When a task runs relative to the main ones; null for a main task. */
service/clover-source-of-truth/service.pkl+1-1
......@@ -2,7 +2,7 @@ amends "../../config/Service.pkl"
22
33import "../../config/site.pkl" as site
44
5meta { name = "Clover DB" }
5meta { name = "Clover DB"; launcher = false }
66
77local isTest = site.domain.endsWith(".test")
88
service/dawarich/service.pkl+1-1
......@@ -43,7 +43,7 @@ local databaseEnv = """
4343 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "\(module.id)-redis" }}REDIS_URL=redis://\(module.nomadHostPort){{ end }}
4444 """
4545
46meta { name = "Dawarich" }
46meta { name = "Dawarich"; access = "infra-admin" }
4747healthyDeadline = "20m"
4848
4949requirements {
service/intake/service.pkl+1-1
......@@ -2,7 +2,7 @@ amends "../../config/Service.pkl"
22
33import "../../config/site.pkl" as site
44
5meta { name = "JSON Intake" }
5meta { name = "JSON Intake"; launcher = false }
66
77secrets = if (site.domain.endsWith(".test")) new { ["key"] {} } else new {}
88requiredSecrets = if (site.domain.endsWith(".test")) new {} else new { "key" }
service/keycloak/service.pkl+1-1
......@@ -15,7 +15,7 @@ class OpenIDClient extends service.Requirement {
1515
1616local database = new postgres.Database { name = "keycloak_next" }
1717
18meta { name = "Keycloak" }
18meta { name = "Keycloak"; launcher = false }
1919traceServiceName = module.id
2020dependsOn { "victoria-traces" }
2121healthyDeadline = "20m"
service/pds/service.pkl+1-1
......@@ -4,7 +4,7 @@ import "../../config/site.pkl" as site
44
55local isolated = site.preview || site.domain.endsWith(".test")
66
7meta { name = "ATProto PDS" }
7meta { name = "ATProto PDS"; launcher = false }
88traceServiceName = module.id
99metricsPushed = true
1010rollout = "simple"
service/shale/theme.css+50-2
......@@ -1,3 +1,43 @@
1.usa-header--basic .usa-nav {
2 background-color: var(--theme-color-primary);
3}
4
5.usa-header--basic .usa-nav__primary > .usa-nav__primary-item > a {
6 color: white;
7}
8
9.usa-header--basic .usa-nav__primary > .usa-nav__primary-item > a:hover {
10 color: white;
11 background-color: rgb(0 0 0 / 15%);
12}
13
14.usa-header--basic a:focus {
15 outline-color: white;
16}
17
18@media (max-width: 40rem) {
19 #page-commit #m-code {
20 grid-template-columns: minmax(0, 1fr);
21 }
22
23 #page-commit #m-code > div {
24 min-width: 0;
25 }
26
27 #page-commit #m-code details {
28 overflow-x: auto;
29 }
30
31 #page-commit #m-metainfo {
32 grid-template-columns: max-content minmax(0, 1fr);
33 }
34
35 #page-commit #m-metainfo td {
36 min-width: 0;
37 overflow-wrap: anywhere;
38 }
39}
40
141@media (prefers-color-scheme: light) {
242 :root {
343 --theme-color-base-lightest: #fff8fb;
......@@ -118,13 +158,17 @@
118158 border-color: #c9aeba;
119159 }
120160
121 .markdown p > code,
161 .markdown :not(pre) > code,
122162 kbd {
123163 color: #543745;
124164 background-color: #f3e4eb;
125165 border-color: #d9bdca;
126166 }
127167
168 :is(td, span).idleage:is(.seconds, .minutes, .hours) {
169 color: #216e1f;
170 }
171
128172 :is(td, span).idleage.weeks,
129173 :is(td, span).idleage.months,
130174 :is(td, span).idleage.years {
......@@ -303,13 +347,17 @@
303347 border-color: #654653;
304348 }
305349
306 .markdown p > code,
350 .markdown :not(pre) > code,
307351 kbd {
308352 color: #f1dce6;
309353 background-color: #38262f;
310354 border-color: #624451;
311355 }
312356
357 :is(td, span).idleage:is(.seconds, .minutes, .hours, .days) {
358 color: #90db9b;
359 }
360
313361 :is(td, span).idleage.weeks,
314362 :is(td, span).idleage.months,
315363 :is(td, span).idleage.years {
tools/studio.py+2-1
......@@ -236,7 +236,8 @@ def render(data, definitions):
236236 f" studio_name = {q(data['name'])}",
237237 f" studio_tagline = {q(data['tagline'])}",
238238 f" studio_hostname = {q(primary.get('hostname') or '')}",
239 f" studio_auth_role = {q(primary.get('authRole') or '')}",
239 f" studio_launcher = {q(str(data['launcher']).lower())}",
240 f" studio_auth_role = {q(data.get('access') or primary.get('authRole') or '')}",
240241 f" studio_metrics_path = {q(primary.get('metricsPath') or '')}",
241242 f" studio_trace_service = {q(data.get('traceServiceName') or '')}",
242243 f" studio_metrics_pushed = {q(str(data['metricsPushed']).lower())}",