| author | |
| committer | |
| log | 0c8b295ae9b40d73e879d1da1b961a6ad5f7c616 |
| tree | ecfa3f45e3c5b952ae3f7853e2b16fbee8d55ac7 |
| parent | 8cd94ab463368b2e16137ffb0d4699104701dc8b |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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-sol13 files changed, 90 insertions(+), 68 deletions(-)
config/Service.pkl+5| ... | ... | @@ -8,6 +8,9 @@ const nomadHostPort = "{{ if regexMatch \":\" .Address }}[{{ .Address }}]{{ else |
| 8 | 8 | class Metadata { |
| 9 | 9 | name: String |
| 10 | 10 | tagline: String = "" |
| 11 | launcher: Boolean = true | |
| 12 | /// Discovery group; absent uses the public route's auth role. | |
| 13 | access: String? | |
| 11 | 14 | } |
| 12 | 15 | |
| 13 | 16 | /// One HTTP listener and its public route. |
| ... | ... | @@ -156,6 +159,8 @@ output { |
| 156 | 159 | id = module.id |
| 157 | 160 | name = module.meta.name |
| 158 | 161 | tagline = module.meta.tagline |
| 162 | launcher = module.meta.launcher | |
| 163 | access = module.meta.access | |
| 159 | 164 | rollout = module.rollout |
| 160 | 165 | stageIsolation = module.stageIsolation |
| 161 | 166 | 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 | 350 | } |
| 351 | 351 | }) |
| 352 | 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 | } |
| 355 | 355 | async fn summaries(app: Arc<App>) -> Result<Arc<Document>> { |
| 356 | 356 | let state = app.clone(); |
| ... | ... | @@ -432,21 +432,13 @@ async fn issues(app: Arc<App>) -> Result<Value> { |
| 432 | 432 | .cache |
| 433 | 433 | .peek("nomad-scan", Duration::from_secs(10)) |
| 434 | 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 | 435 | let mut issues = Vec::new(); |
| 437 | 436 | for service in array(&summaries.value) { |
| 438 | 437 | let id = string(&service["id"]); |
| 439 | 438 | let health = string(&service["health"]); |
| 440 | 439 | 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())})); | |
| 450 | 442 | } |
| 451 | 443 | } |
| 452 | 444 | Ok(json!(issues)) |
| ... | ... | @@ -493,9 +485,9 @@ async fn launcher_module(repo: &Path) -> Result<String> { |
| 493 | 485 | } |
| 494 | 486 | module.push_str(&format!("local services = Map({})\n", services.join(", "))); |
| 495 | 487 | module.push_str(r#"output { renderer = new JsonRenderer { omitNullProperties = false } |
| 496 | value = services.filter((_, s) -> s.enabled).mapValues((_, s) -> | |
| 488 | value = services.filter((_, s) -> s.enabled && s.meta.launcher).mapValues((_, s) -> | |
| 497 | 489 | let (route = (s.containers?.toMap()?.values ?? List()).add(s.container).filterNonNull().map((task) -> task.http).filterNonNull().findOrNull((http) -> http.hostname != null)) |
| 498 | new Dynamic { name = s.meta.name; tagline = s.meta.tagline; hostname = route?.hostname; access = route?.authRole }) }"#); | |
| 490 | new Dynamic { name = s.meta.name; tagline = s.meta.tagline; hostname = route?.hostname; access = s.meta.access ?? route?.authRole }) }"#); | |
| 499 | 491 | Ok(module) |
| 500 | 492 | } |
| 501 | 493 | |
| ... | ... | @@ -690,7 +682,13 @@ pub async fn route( |
| 690 | 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 | 692 | .collect::<Vec<_>>() |
| 695 | 693 | }); |
| 696 | 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 | 15 | import Plug from "lucide-solid/icons/plug"; |
| 16 | 16 | import Users from "lucide-solid/icons/users"; |
| 17 | 17 | import { createSignal, For, type JSX, Show } from "solid-js"; |
| 18 | import { type Health, type Me, type Section, trouble, VIEW_AS } from "../types/model.ts"; | |
| 18 | import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts"; | |
| 19 | 19 | import { queries } from "../api.ts"; |
| 20 | 20 | import snowflake from "../snowflake.svg"; |
| 21 | 21 | import { bytes, cores, plural } from "../format.ts"; |
| ... | ... | @@ -75,7 +75,7 @@ function IssueCount() { |
| 75 | 75 | <Show when={issues().length}> |
| 76 | 76 | <span class="badge"> |
| 77 | 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 | 79 | <StatusLabel health={issues().some((issue) => issue.health === "down") ? "down" : "degraded"}>{issues().length}</StatusLabel> |
| 80 | 80 | <span class="sr-only">need a look</span> |
| 81 | 81 | </span> |
| ... | ... | @@ -223,7 +223,7 @@ export function viewAs(groups: string[] | null) { |
| 223 | 223 | location.reload(); |
| 224 | 224 | } |
| 225 | 225 | |
| 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. */ | |
| 227 | 227 | function Whoami(props: { me: Me }) { |
| 228 | 228 | const user = lastGood(account); |
| 229 | 229 | const name = () => { |
| ... | ... | @@ -237,12 +237,14 @@ function Whoami(props: { me: Me }) { |
| 237 | 237 | <div class="whoami"> |
| 238 | 238 | <button class="whoami-button" classList={{ active: !!here() }} popovertarget="account-menu"> |
| 239 | 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 | 243 | <ChevronsUpDown size={14} class="icon" /> |
| 242 | 244 | </button> |
| 243 | 245 | <div ref={menu} id="account-menu" popover class="account-menu"> |
| 244 | 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 | 248 | <Show when={props.me.viewing || props.me.sections.includes("admin")}> |
| 247 | 249 | <form class="view-as" onSubmit={(event) => { |
| 248 | 250 | event.preventDefault(); |
dashboard/web/pages/Overview.css-2| ... | ... | @@ -81,9 +81,7 @@ |
| 81 | 81 | .changes .muted a { color: var(--text-2); text-decoration: underline dotted var(--axis); text-underline-offset: 3px; } |
| 82 | 82 | .changes a[href]:hover { color: var(--accent); text-decoration-color: currentColor; } |
| 83 | 83 | .changes.issues { border-bottom: 1px solid var(--grid); } |
| 84 | .issues li:has(> button) { grid-template-columns: 14px 1fr auto auto; } | |
| 85 | 84 | .issues li > .status { justify-self: center; } |
| 86 | .issues .button.icon-only { --control: 22px; width: 22px; } | |
| 87 | 85 | .changes time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } |
| 88 | 86 | |
| 89 | 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 | 5 | import HardDrive from "lucide-solid/icons/hard-drive"; |
| 6 | 6 | import Power from "lucide-solid/icons/power"; |
| 7 | 7 | import Rocket from "lucide-solid/icons/rocket"; |
| 8 | import RotateCcw from "lucide-solid/icons/rotate-ccw"; | |
| 9 | 8 | import ShieldCheck from "lucide-solid/icons/shield-check"; |
| 10 | 9 | import Trash from "lucide-solid/icons/trash"; |
| 11 | 10 | import Undo2 from "lucide-solid/icons/undo-2"; |
| 12 | import X from "lucide-solid/icons/x"; | |
| 13 | 11 | import { createMemo, createResource, createRoot, createSignal, For, type JSX, onCleanup, Show } from "solid-js"; |
| 14 | 12 | import { type Health, type HostInfo, type Live, type Me, type Series, type ServiceSummary, trouble } from "../types/model.ts"; |
| 15 | 13 | import { api, queries, reason } from "../api.ts"; |
| ... | ... | @@ -69,46 +67,19 @@ function Greeting(props: { me: Me }) { |
| 69 | 67 | ); |
| 70 | 68 | } |
| 71 | 69 | |
| 72 | /** Apps that are down or degraded or restarted unacknowledged; admins reach each service and clear its restart. */ | |
| 73 | 70 | function 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 | 71 | return ( |
| 77 | 72 | <Show when={status.latest()?.issues?.length}> |
| 78 | 73 | <ul class="changes issues" aria-label="Needs a look"> |
| 79 | 74 | <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 | )} | |
| 112 | 83 | </For> |
| 113 | 84 | </ul> |
| 114 | 85 | </Show> |
dashboard/web/types/model.ts+1-2| ... | ... | @@ -53,13 +53,12 @@ export interface ServiceSummary { |
| 53 | 53 | /** States that need someone to look, unlike stopped (on purpose) or the passing ones like restarting. */ |
| 54 | 54 | export const trouble = (health: Health) => health === "down" || health === "degraded"; |
| 55 | 55 | |
| 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. */ | |
| 57 | 57 | export interface Issue { |
| 58 | 58 | service: Pick<ServiceSummary, "id" | "name" | "icon" | "url">; |
| 59 | 59 | health: Health; |
| 60 | 60 | /** The failing check's output while it's down or degraded. */ |
| 61 | 61 | failing: string | null; |
| 62 | restart: { t: number; reason: string } | null; | |
| 63 | 62 | } |
| 64 | 63 | |
| 65 | 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 | 2 | |
| 3 | 3 | import "../../config/site.pkl" as site |
| 4 | 4 | |
| 5 | meta { name = "Clover DB" } | |
| 5 | meta { name = "Clover DB"; launcher = false } | |
| 6 | 6 | |
| 7 | 7 | local isTest = site.domain.endsWith(".test") |
| 8 | 8 |
service/dawarich/service.pkl+1-1| ... | ... | @@ -43,7 +43,7 @@ local databaseEnv = """ |
| 43 | 43 | {{ range nomadService 1 (env "NOMAD_ALLOC_ID") "\(module.id)-redis" }}REDIS_URL=redis://\(module.nomadHostPort){{ end }} |
| 44 | 44 | """ |
| 45 | 45 | |
| 46 | meta { name = "Dawarich" } | |
| 46 | meta { name = "Dawarich"; access = "infra-admin" } | |
| 47 | 47 | healthyDeadline = "20m" |
| 48 | 48 | |
| 49 | 49 | requirements { |
service/intake/service.pkl+1-1| ... | ... | @@ -2,7 +2,7 @@ amends "../../config/Service.pkl" |
| 2 | 2 | |
| 3 | 3 | import "../../config/site.pkl" as site |
| 4 | 4 | |
| 5 | meta { name = "JSON Intake" } | |
| 5 | meta { name = "JSON Intake"; launcher = false } | |
| 6 | 6 | |
| 7 | 7 | secrets = if (site.domain.endsWith(".test")) new { ["key"] {} } else new {} |
| 8 | 8 | requiredSecrets = 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 | 15 | |
| 16 | 16 | local database = new postgres.Database { name = "keycloak_next" } |
| 17 | 17 | |
| 18 | meta { name = "Keycloak" } | |
| 18 | meta { name = "Keycloak"; launcher = false } | |
| 19 | 19 | traceServiceName = module.id |
| 20 | 20 | dependsOn { "victoria-traces" } |
| 21 | 21 | healthyDeadline = "20m" |
service/pds/service.pkl+1-1| ... | ... | @@ -4,7 +4,7 @@ import "../../config/site.pkl" as site |
| 4 | 4 | |
| 5 | 5 | local isolated = site.preview || site.domain.endsWith(".test") |
| 6 | 6 | |
| 7 | meta { name = "ATProto PDS" } | |
| 7 | meta { name = "ATProto PDS"; launcher = false } | |
| 8 | 8 | traceServiceName = module.id |
| 9 | 9 | metricsPushed = true |
| 10 | 10 | rollout = "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 | ||
| 1 | 41 | @media (prefers-color-scheme: light) { |
| 2 | 42 | :root { |
| 3 | 43 | --theme-color-base-lightest: #fff8fb; |
| ... | ... | @@ -118,13 +158,17 @@ |
| 118 | 158 | border-color: #c9aeba; |
| 119 | 159 | } |
| 120 | 160 | |
| 121 | .markdown p > code, | |
| 161 | .markdown :not(pre) > code, | |
| 122 | 162 | kbd { |
| 123 | 163 | color: #543745; |
| 124 | 164 | background-color: #f3e4eb; |
| 125 | 165 | border-color: #d9bdca; |
| 126 | 166 | } |
| 127 | 167 | |
| 168 | :is(td, span).idleage:is(.seconds, .minutes, .hours) { | |
| 169 | color: #216e1f; | |
| 170 | } | |
| 171 | ||
| 128 | 172 | :is(td, span).idleage.weeks, |
| 129 | 173 | :is(td, span).idleage.months, |
| 130 | 174 | :is(td, span).idleage.years { |
| ... | ... | @@ -303,13 +347,17 @@ |
| 303 | 347 | border-color: #654653; |
| 304 | 348 | } |
| 305 | 349 | |
| 306 | .markdown p > code, | |
| 350 | .markdown :not(pre) > code, | |
| 307 | 351 | kbd { |
| 308 | 352 | color: #f1dce6; |
| 309 | 353 | background-color: #38262f; |
| 310 | 354 | border-color: #624451; |
| 311 | 355 | } |
| 312 | 356 | |
| 357 | :is(td, span).idleage:is(.seconds, .minutes, .hours, .days) { | |
| 358 | color: #90db9b; | |
| 359 | } | |
| 360 | ||
| 313 | 361 | :is(td, span).idleage.weeks, |
| 314 | 362 | :is(td, span).idleage.months, |
| 315 | 363 | :is(td, span).idleage.years { |
tools/studio.py+2-1| ... | ... | @@ -236,7 +236,8 @@ def render(data, definitions): |
| 236 | 236 | f" studio_name = {q(data['name'])}", |
| 237 | 237 | f" studio_tagline = {q(data['tagline'])}", |
| 238 | 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 | 241 | f" studio_metrics_path = {q(primary.get('metricsPath') or '')}", |
| 241 | 242 | f" studio_trace_service = {q(data.get('traceServiceName') or '')}", |
| 242 | 243 | f" studio_metrics_pushed = {q(str(data['metricsPushed']).lower())}", |