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...@@ -8,6 +8,9 @@ const nomadHostPort = "{{ if regexMatch \":\" .Address }}[{{ .Address }}]{{ else
8class Metadata {8class Metadata {
9 name: String9 name: String
10 tagline: String = ""10 tagline: String = ""
11 launcher: Boolean = true
12 /// Discovery group; absent uses the public route's auth role.
13 access: String?
11}14}
1215
13/// One HTTP listener and its public route.16/// One HTTP listener and its public route.
...@@ -156,6 +159,8 @@ output {...@@ -156,6 +159,8 @@ output {
156 id = module.id159 id = module.id
157 name = module.meta.name160 name = module.meta.name
158 tagline = module.meta.tagline161 tagline = module.meta.tagline
162 launcher = module.meta.launcher
163 access = module.meta.access
159 rollout = module.rollout164 rollout = module.rollout
160 stageIsolation = module.stageIsolation165 stageIsolation = module.stageIsolation
161 healthyDeadline = vmStartupGrace ?? module.healthyDeadline166 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 {...@@ -350,7 +350,7 @@ async fn summarize(app: Arc<App>, id: &str, found: &Value) -> Value {
350 }350 }
351 })351 })
352 .sum();352 .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})
354}354}
355async fn summaries(app: Arc<App>) -> Result<Arc<Document>> {355async fn summaries(app: Arc<App>) -> Result<Arc<Document>> {
356 let state = app.clone();356 let state = app.clone();
...@@ -432,21 +432,13 @@ async fn issues(app: Arc<App>) -> Result<Value> {...@@ -432,21 +432,13 @@ async fn issues(app: Arc<App>) -> Result<Value> {
432 .cache432 .cache
433 .peek("nomad-scan", Duration::from_secs(10))433 .peek("nomad-scan", Duration::from_secs(10))
434 .ok_or_else(|| Error::new(503, "Service status is unavailable."))?;434 .ok_or_else(|| Error::new(503, "Service status is unavailable."))?;
435 let ack = read_json(&app.data.join("restarts-acknowledged.json"), json!({})).await?;
436 let mut issues = Vec::new();435 let mut issues = Vec::new();
437 for service in array(&summaries.value) {436 for service in array(&summaries.value) {
438 let id = string(&service["id"]);437 let id = string(&service["id"]);
439 let health = string(&service["health"]);438 let health = string(&service["health"]);
440 let found = &jobs.value[id];439 let found = &jobs.value[id];
441 let cs = containers(found);440 if matches!(health, "down" | "degraded") {
442 let restart = array(&cs)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())}));
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}));
450 }442 }
451 }443 }
452 Ok(json!(issues))444 Ok(json!(issues))
...@@ -493,9 +485,9 @@ async fn launcher_module(repo: &Path) -> Result<String> {...@@ -493,9 +485,9 @@ async fn launcher_module(repo: &Path) -> Result<String> {
493 }485 }
494 module.push_str(&format!("local services = Map({})\n", services.join(", ")));486 module.push_str(&format!("local services = Map({})\n", services.join(", ")));
495 module.push_str(r#"output { renderer = new JsonRenderer { omitNullProperties = false }487 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) ->
497let (route = (s.containers?.toMap()?.values ?? List()).add(s.container).filterNonNull().map((task) -> task.http).filterNonNull().findOrNull((http) -> http.hostname != null))489let (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 }) }"#);
499 Ok(module)491 Ok(module)
500}492}
501493
...@@ -690,7 +682,13 @@ pub async fn route(...@@ -690,7 +682,13 @@ pub async fn route(
690 && can_open(&groups, a["access"].as_str())682 && can_open(&groups, a["access"].as_str())
691 })683 })
692 })684 })
693 .cloned()685 .map(|issue| {
686 let mut issue = issue.clone();
687 if !admin {
688 issue["failing"] = Value::Null;
689 }
690 issue
691 })
694 .collect::<Vec<_>>()692 .collect::<Vec<_>>()
695 });693 });
696 let sample = host::sample(app).await?;694 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";...@@ -15,7 +15,7 @@ import UserRound from "lucide-solid/icons/user-round";
15import Plug from "lucide-solid/icons/plug";15import Plug from "lucide-solid/icons/plug";
16import Users from "lucide-solid/icons/users";16import Users from "lucide-solid/icons/users";
17import { createSignal, For, type JSX, Show } from "solid-js";17import { 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";
19import { queries } from "../api.ts";19import { queries } from "../api.ts";
20import snowflake from "../snowflake.svg";20import snowflake from "../snowflake.svg";
21import { bytes, cores, plural } from "../format.ts";21import { bytes, cores, plural } from "../format.ts";
...@@ -75,7 +75,7 @@ function IssueCount() {...@@ -75,7 +75,7 @@ function IssueCount() {
75 <Show when={issues().length}>75 <Show when={issues().length}>
76 <span class="badge">76 <span class="badge">
77 <span class="tally" data-tip={issues().map((issue) =>77 <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(", ")}>
79 <StatusLabel health={issues().some((issue) => issue.health === "down") ? "down" : "degraded"}>{issues().length}</StatusLabel>79 <StatusLabel health={issues().some((issue) => issue.health === "down") ? "down" : "degraded"}>{issues().length}</StatusLabel>
80 <span class="sr-only">need a look</span>80 <span class="sr-only">need a look</span>
81 </span>81 </span>
...@@ -223,7 +223,7 @@ export function viewAs(groups: string[] | null) {...@@ -223,7 +223,7 @@ export function viewAs(groups: string[] | null) {
223 location.reload();223 location.reload();
224}224}
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. */
227function Whoami(props: { me: Me }) {227function Whoami(props: { me: Me }) {
228 const user = lastGood(account);228 const user = lastGood(account);
229 const name = () => {229 const name = () => {
...@@ -237,12 +237,14 @@ function Whoami(props: { me: Me }) {...@@ -237,12 +237,14 @@ function Whoami(props: { me: Me }) {
237 <div class="whoami">237 <div class="whoami">
238 <button class="whoami-button" classList={{ active: !!here() }} popovertarget="account-menu">238 <button class="whoami-button" classList={{ active: !!here() }} popovertarget="account-menu">
239 <Avatar picture={user()?.picture ?? null} name={name()} />239 <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>
241 <ChevronsUpDown size={14} class="icon" />243 <ChevronsUpDown size={14} class="icon" />
242 </button>244 </button>
243 <div ref={menu} id="account-menu" popover class="account-menu">245 <div ref={menu} id="account-menu" popover class="account-menu">
244 <A href="/account" class="nav-item" onClick={() => menu.hidePopover()}><UserRound class="icon" /><span class="label">profile</span></A>246 <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>
246 <Show when={props.me.viewing || props.me.sections.includes("admin")}>248 <Show when={props.me.viewing || props.me.sections.includes("admin")}>
247 <form class="view-as" onSubmit={(event) => {249 <form class="view-as" onSubmit={(event) => {
248 event.preventDefault();250 event.preventDefault();
dashboard/web/pages/Overview.css-2
...@@ -81,9 +81,7 @@...@@ -81,9 +81,7 @@
81.changes .muted a { color: var(--text-2); text-decoration: underline dotted var(--axis); text-underline-offset: 3px; }81.changes .muted a { color: var(--text-2); text-decoration: underline dotted var(--axis); text-underline-offset: 3px; }
82.changes a[href]:hover { color: var(--accent); text-decoration-color: currentColor; }82.changes a[href]:hover { color: var(--accent); text-decoration-color: currentColor; }
83.changes.issues { border-bottom: 1px solid var(--grid); }83.changes.issues { border-bottom: 1px solid var(--grid); }
84.issues li:has(> button) { grid-template-columns: 14px 1fr auto auto; }
85.issues li > .status { justify-self: center; }84.issues li > .status { justify-self: center; }
86.issues .button.icon-only { --control: 22px; width: 22px; }
87.changes time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }85.changes time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
8886
89.section-head { display: flex; align-items: center; gap: 12px; margin: 22px 0 6px; }87.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";...@@ -5,11 +5,9 @@ import BatteryWarning from "lucide-solid/icons/battery-warning";
5import HardDrive from "lucide-solid/icons/hard-drive";5import HardDrive from "lucide-solid/icons/hard-drive";
6import Power from "lucide-solid/icons/power";6import Power from "lucide-solid/icons/power";
7import Rocket from "lucide-solid/icons/rocket";7import Rocket from "lucide-solid/icons/rocket";
8import RotateCcw from "lucide-solid/icons/rotate-ccw";
9import ShieldCheck from "lucide-solid/icons/shield-check";8import ShieldCheck from "lucide-solid/icons/shield-check";
10import Trash from "lucide-solid/icons/trash";9import Trash from "lucide-solid/icons/trash";
11import Undo2 from "lucide-solid/icons/undo-2";10import Undo2 from "lucide-solid/icons/undo-2";
12import X from "lucide-solid/icons/x";
13import { createMemo, createResource, createRoot, createSignal, For, type JSX, onCleanup, Show } from "solid-js";11import { createMemo, createResource, createRoot, createSignal, For, type JSX, onCleanup, Show } from "solid-js";
14import { type Health, type HostInfo, type Live, type Me, type Series, type ServiceSummary, trouble } from "../types/model.ts";12import { type Health, type HostInfo, type Live, type Me, type Series, type ServiceSummary, trouble } from "../types/model.ts";
15import { api, queries, reason } from "../api.ts";13import { api, queries, reason } from "../api.ts";
...@@ -69,46 +67,19 @@ function Greeting(props: { me: Me }) {...@@ -69,46 +67,19 @@ function Greeting(props: { me: Me }) {
69 );67 );
70}68}
7169
72/** Apps that are down or degraded or restarted unacknowledged; admins reach each service and clear its restart. */
73function Issues(props: { admin: boolean }) {70function 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)}`));
76 return (71 return (
77 <Show when={status.latest()?.issues?.length}>72 <Show when={status.latest()?.issues?.length}>
78 <ul class="changes issues" aria-label="Needs a look">73 <ul class="changes issues" aria-label="Needs a look">
79 <For each={status.latest()!.issues!}>74 <For each={status.latest()!.issues!}>
80 {(issue) => {75 {(issue) => (
81 const [clearing, setClearing] = createSignal(false);76 <li class="warn">
82 const name = () => props.admin ? <a href={`/services/${issue.service.id}`}>{issue.service.name}</a> : issue.service.name;77 <Status health={issue.health} />
83 return (78 <span class="what"><Show when={props.admin} fallback={issue.service.name}>
84 <>79 <a href={`/services/${issue.service.id}`}>{issue.service.name}</a>
85 <Show when={trouble(issue.health)}>80 </Show> {issue.health} <span class="muted">{issue.failing}</span></span>
86 <li class="warn">81 </li>
87 <Status health={issue.health} />82 )}
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 }}
112 </For>83 </For>
113 </ul>84 </ul>
114 </Show>85 </Show>
dashboard/web/types/model.ts+1-2
...@@ -53,13 +53,12 @@ export interface ServiceSummary {...@@ -53,13 +53,12 @@ export interface ServiceSummary {
53/** States that need someone to look, unlike stopped (on purpose) or the passing ones like restarting. */53/** States that need someone to look, unlike stopped (on purpose) or the passing ones like restarting. */
54export const trouble = (health: Health) => health === "down" || health === "degraded";54export 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. */
57export interface Issue {57export interface Issue {
58 service: Pick<ServiceSummary, "id" | "name" | "icon" | "url">;58 service: Pick<ServiceSummary, "id" | "name" | "icon" | "url">;
59 health: Health;59 health: Health;
60 /** The failing check's output while it's down or degraded. */60 /** The failing check's output while it's down or degraded. */
61 failing: string | null;61 failing: string | null;
62 restart: { t: number; reason: string } | null;
63}62}
6463
65/** When a task runs relative to the main ones; null for a main task. */64/** 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"...@@ -2,7 +2,7 @@ amends "../../config/Service.pkl"
22
3import "../../config/site.pkl" as site3import "../../config/site.pkl" as site
44
5meta { name = "Clover DB" }5meta { name = "Clover DB"; launcher = false }
66
7local isTest = site.domain.endsWith(".test")7local isTest = site.domain.endsWith(".test")
88
service/dawarich/service.pkl+1-1
...@@ -43,7 +43,7 @@ local databaseEnv = """...@@ -43,7 +43,7 @@ local databaseEnv = """
43 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "\(module.id)-redis" }}REDIS_URL=redis://\(module.nomadHostPort){{ end }}43 {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "\(module.id)-redis" }}REDIS_URL=redis://\(module.nomadHostPort){{ end }}
44 """44 """
4545
46meta { name = "Dawarich" }46meta { name = "Dawarich"; access = "infra-admin" }
47healthyDeadline = "20m"47healthyDeadline = "20m"
4848
49requirements {49requirements {
service/intake/service.pkl+1-1
...@@ -2,7 +2,7 @@ amends "../../config/Service.pkl"...@@ -2,7 +2,7 @@ amends "../../config/Service.pkl"
22
3import "../../config/site.pkl" as site3import "../../config/site.pkl" as site
44
5meta { name = "JSON Intake" }5meta { name = "JSON Intake"; launcher = false }
66
7secrets = if (site.domain.endsWith(".test")) new { ["key"] {} } else new {}7secrets = if (site.domain.endsWith(".test")) new { ["key"] {} } else new {}
8requiredSecrets = if (site.domain.endsWith(".test")) new {} else new { "key" }8requiredSecrets = if (site.domain.endsWith(".test")) new {} else new { "key" }
service/keycloak/service.pkl+1-1
...@@ -15,7 +15,7 @@ class OpenIDClient extends service.Requirement {...@@ -15,7 +15,7 @@ class OpenIDClient extends service.Requirement {
1515
16local database = new postgres.Database { name = "keycloak_next" }16local database = new postgres.Database { name = "keycloak_next" }
1717
18meta { name = "Keycloak" }18meta { name = "Keycloak"; launcher = false }
19traceServiceName = module.id19traceServiceName = module.id
20dependsOn { "victoria-traces" }20dependsOn { "victoria-traces" }
21healthyDeadline = "20m"21healthyDeadline = "20m"
service/pds/service.pkl+1-1
...@@ -4,7 +4,7 @@ import "../../config/site.pkl" as site...@@ -4,7 +4,7 @@ import "../../config/site.pkl" as site
44
5local isolated = site.preview || site.domain.endsWith(".test")5local isolated = site.preview || site.domain.endsWith(".test")
66
7meta { name = "ATProto PDS" }7meta { name = "ATProto PDS"; launcher = false }
8traceServiceName = module.id8traceServiceName = module.id
9metricsPushed = true9metricsPushed = true
10rollout = "simple"10rollout = "simple"
service/shale/theme.css+50-2
...@@ -1,3 +1,43 @@...@@ -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
1@media (prefers-color-scheme: light) {41@media (prefers-color-scheme: light) {
2 :root {42 :root {
3 --theme-color-base-lightest: #fff8fb;43 --theme-color-base-lightest: #fff8fb;
...@@ -118,13 +158,17 @@...@@ -118,13 +158,17 @@
118 border-color: #c9aeba;158 border-color: #c9aeba;
119 }159 }
120160
121 .markdown p > code,161 .markdown :not(pre) > code,
122 kbd {162 kbd {
123 color: #543745;163 color: #543745;
124 background-color: #f3e4eb;164 background-color: #f3e4eb;
125 border-color: #d9bdca;165 border-color: #d9bdca;
126 }166 }
127167
168 :is(td, span).idleage:is(.seconds, .minutes, .hours) {
169 color: #216e1f;
170 }
171
128 :is(td, span).idleage.weeks,172 :is(td, span).idleage.weeks,
129 :is(td, span).idleage.months,173 :is(td, span).idleage.months,
130 :is(td, span).idleage.years {174 :is(td, span).idleage.years {
...@@ -303,13 +347,17 @@...@@ -303,13 +347,17 @@
303 border-color: #654653;347 border-color: #654653;
304 }348 }
305349
306 .markdown p > code,350 .markdown :not(pre) > code,
307 kbd {351 kbd {
308 color: #f1dce6;352 color: #f1dce6;
309 background-color: #38262f;353 background-color: #38262f;
310 border-color: #624451;354 border-color: #624451;
311 }355 }
312356
357 :is(td, span).idleage:is(.seconds, .minutes, .hours, .days) {
358 color: #90db9b;
359 }
360
313 :is(td, span).idleage.weeks,361 :is(td, span).idleage.weeks,
314 :is(td, span).idleage.months,362 :is(td, span).idleage.months,
315 :is(td, span).idleage.years {363 :is(td, span).idleage.years {
tools/studio.py+2-1
...@@ -236,7 +236,8 @@ def render(data, definitions):...@@ -236,7 +236,8 @@ def render(data, definitions):
236 f" studio_name = {q(data['name'])}",236 f" studio_name = {q(data['name'])}",
237 f" studio_tagline = {q(data['tagline'])}",237 f" studio_tagline = {q(data['tagline'])}",
238 f" studio_hostname = {q(primary.get('hostname') or '')}",238 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 '')}",
240 f" studio_metrics_path = {q(primary.get('metricsPath') or '')}",241 f" studio_metrics_path = {q(primary.get('metricsPath') or '')}",
241 f" studio_trace_service = {q(data.get('traceServiceName') or '')}",242 f" studio_trace_service = {q(data.get('traceServiceName') or '')}",
242 f" studio_metrics_pushed = {q(str(data['metricsPushed']).lower())}",243 f" studio_metrics_pushed = {q(str(data['metricsPushed']).lower())}",