| 1 | /** Shapes and rules shared by the server and the web app; keep this free of Node imports. */ |
| 2 | |
| 3 | export type Health = "healthy" | "degraded" | "down" | "stopped" | "deploying" | "restarting" | "starting"; |
| 4 | |
| 5 | /** Columnar series, the layout uPlot consumes directly. Times are unix seconds. */ |
| 6 | export interface Series { |
| 7 | name: string; |
| 8 | t: number[]; |
| 9 | v: (number | null)[]; |
| 10 | } |
| 11 | |
| 12 | export interface Me { |
| 13 | name: string; |
| 14 | groups: string[]; |
| 15 | sections: Section[]; |
| 16 | /** An admin previewing the dashboard as `groups`. */ |
| 17 | viewing: boolean; |
| 18 | } |
| 19 | |
| 20 | /** Cookie holding the comma-separated groups an admin previews the dashboard as. */ |
| 21 | export const VIEW_AS = "view-as"; |
| 22 | |
| 23 | /** The Keycloak group that opens each dashboard section; null opens it to everyone. */ |
| 24 | const SECTION_GROUPS = { launcher: null, admin: "infra-admin", metrics: "metrics", media: "media-manage", vms: "vm" } as const; |
| 25 | export type Section = keyof typeof SECTION_GROUPS; |
| 26 | |
| 27 | /** Admins reach everything; `access` null is open to every signed-in user. */ |
| 28 | export const canOpen = (groups: string[], access: string | null) => |
| 29 | access === null || groups.includes("infra-admin") || groups.includes(access); |
| 30 | |
| 31 | export const sectionsOf = (groups: string[]) => |
| 32 | (Object.keys(SECTION_GROUPS) as Section[]).filter((section) => canOpen(groups, SECTION_GROUPS[section])); |
| 33 | |
| 34 | export interface ServiceSummary { |
| 35 | id: string; |
| 36 | name: string; |
| 37 | health: Health; |
| 38 | url: string | null; |
| 39 | /** Versioned image URLs per color scheme; the same URL twice when the service has one image. */ |
| 40 | icon: { light: string; dark: string } | null; |
| 41 | /** Keycloak group that sees this service in the launcher; null means everyone. */ |
| 42 | access: string | null; |
| 43 | /** What the app is for, in a few words, for people who don't know it by name. */ |
| 44 | tagline: string | null; |
| 45 | /** Cores in use and reserved; use is null while Nomad doesn't measure it. */ |
| 46 | cpu: number | null; |
| 47 | cpuLimit: number; |
| 48 | /** Bytes in use and reserved; use is null while Nomad doesn't measure it. */ |
| 49 | memory: number | null; |
| 50 | memoryLimit: number; |
| 51 | } |
| 52 | |
| 53 | /** States that need someone to look, unlike stopped (on purpose) or the passing ones like restarting. */ |
| 54 | export const trouble = (health: Health) => health === "down" || health === "degraded"; |
| 55 | |
| 56 | /** A service that is currently down or degraded. */ |
| 57 | export interface Issue { |
| 58 | service: Pick<ServiceSummary, "id" | "name" | "icon" | "url">; |
| 59 | health: Health; |
| 60 | /** The failing check's output while it's down or degraded. */ |
| 61 | failing: string | null; |
| 62 | } |
| 63 | |
| 64 | /** When a task runs relative to the main ones; null for a main task. */ |
| 65 | export type Hook = "prestart" | "poststart" | "poststop" | null; |
| 66 | |
| 67 | /** A Nomad task; its restarts count within the current allocation. */ |
| 68 | export interface Container { |
| 69 | name: string; |
| 70 | /** As configured; null for tasks that don't run an image. */ |
| 71 | image: string | null; |
| 72 | hook: Hook; |
| 73 | state: "running" | "pending" | "dead"; |
| 74 | restarts: number; |
| 75 | lastRestart: { t: number; reason: string } | null; |
| 76 | startedAt: number | null; |
| 77 | } |
| 78 | |
| 79 | /** The service on the other end of a dependency, and what it provides ("database", "sign-in"). */ |
| 80 | export interface ServiceLink { |
| 81 | id: string; |
| 82 | name: string; |
| 83 | kind: string; |
| 84 | health: Health; |
| 85 | } |
| 86 | |
| 87 | /** The latest restart, unless it happened before restarts were last acknowledged. */ |
| 88 | export function unacknowledgedRestart(service: Pick<ServiceDetail, "containers" | "restartsAcknowledged">) { |
| 89 | const last = service.containers.flatMap((container) => container.lastRestart ?? []).sort((a, b) => b.t - a.t)[0]; |
| 90 | return last && (service.restartsAcknowledged === null || last.t > service.restartsAcknowledged) ? last : null; |
| 91 | } |
| 92 | |
| 93 | /** Lists that are null come from a source the host doesn't expose yet. */ |
| 94 | export interface ServiceDetail extends ServiceSummary { |
| 95 | /** Application spans observed in the trace store, excluding Caddy's edge spans. */ |
| 96 | applicationTraces: boolean; |
| 97 | /** Metric names ingested for this service. */ |
| 98 | applicationMetrics: string[]; |
| 99 | /** The release this service's job was rendered from. */ |
| 100 | release: string | null; |
| 101 | /** When Nomad was last handed this job. */ |
| 102 | deployedAt: number; |
| 103 | rollout: "simple" | "overlapped"; |
| 104 | containers: Container[]; |
| 105 | checks: { name: string; passing: boolean; output: string }[]; |
| 106 | requirements: ServiceLink[] | null; |
| 107 | dependents: ServiceLink[] | null; |
| 108 | /** Generated secrets can be rotated; the rest are supplied by hand. */ |
| 109 | secrets: { name: string; generated: boolean }[] | null; |
| 110 | /** `used` excludes what snapshots hold. */ |
| 111 | datasets: { name: string; mountpoint: string; used: number; snapshots: number }[]; |
| 112 | /** When restarts were last acknowledged; ones before it no longer need attention. */ |
| 113 | restartsAcknowledged: number | null; |
| 114 | /** This service's logs in the Logs web UI; null when that UI isn't set up. */ |
| 115 | logs: AppLink | null; |
| 116 | } |
| 117 | |
| 118 | /** A service's Nomad job as submitted, one entry per task group. Durations are seconds. */ |
| 119 | export interface ServiceDefinition { |
| 120 | groups: { |
| 121 | name: string; |
| 122 | count: number; |
| 123 | /** Restarts allowed per `interval`, `delay` apart; once spent, `fail` gives up instead of waiting out the interval. */ |
| 124 | restart: { attempts: number; interval: number; delay: number; fail: boolean }; |
| 125 | services: { |
| 126 | name: string; |
| 127 | port: string; |
| 128 | /** Hostnames the router sends here; empty for a service only other services reach. */ |
| 129 | hostnames: string[]; |
| 130 | /** The Keycloak group that must sign in first; null for no sign-in gate. */ |
| 131 | authRole: string | null; |
| 132 | /** `restartAfter` failures in a row restart `task`, counted once `grace` has passed since it started. */ |
| 133 | check: { |
| 134 | task: string; |
| 135 | type: string; |
| 136 | path: string; |
| 137 | interval: number; |
| 138 | timeout: number; |
| 139 | restartAfter: { failures: number; grace: number } | null; |
| 140 | } | null; |
| 141 | }[]; |
| 142 | tasks: TaskDefinition[]; |
| 143 | }[]; |
| 144 | /** The service file in the current release; null when the release has none. */ |
| 145 | source: string | null; |
| 146 | } |
| 147 | |
| 148 | export interface TaskDefinition { |
| 149 | name: string; |
| 150 | hook: Hook; |
| 151 | /** A hook task that keeps running beside the main ones. */ |
| 152 | sidecar: boolean; |
| 153 | image: string | null; |
| 154 | /** `uid:gid`; null runs as the image's user. */ |
| 155 | user: string | null; |
| 156 | /** Cores and bytes reserved; `memoryMax` is null without room to burst past `memory`. */ |
| 157 | cpu: number; |
| 158 | memory: number; |
| 159 | memoryMax: number | null; |
| 160 | /** `host` is null for a port Nomad picks at each start; `network` is "loopback" or "default". */ |
| 161 | ports: { label: string; container: number | null; host: number | null; network: string }[]; |
| 162 | mounts: { source: string; target: string; readOnly: boolean }[]; |
| 163 | tmpfs: string[]; |
| 164 | devices: string[]; |
| 165 | capabilities: string[]; |
| 166 | hostNetwork: boolean; |
| 167 | extraHosts: string[]; |
| 168 | /** Variable names only; values never leave the server. `secret` and `template` ones are filled in as the task starts. */ |
| 169 | env: { name: string; from: "job" | "secret" | "template" }[]; |
| 170 | } |
| 171 | |
| 172 | /** A deep link into another app's web UI. */ |
| 173 | export interface AppLink { |
| 174 | app: Pick<ServiceSummary, "id" | "name" | "icon">; |
| 175 | url: string; |
| 176 | } |
| 177 | |
| 178 | /** An OpenTelemetry span; times are unix seconds. */ |
| 179 | export interface Span { |
| 180 | id: string; |
| 181 | parent: string | null; |
| 182 | service: string; |
| 183 | name: string; |
| 184 | start: number; |
| 185 | duration: number; |
| 186 | /** The status message of a failed span. */ |
| 187 | error: string | null; |
| 188 | attributes: Record<string, string | number>; |
| 189 | } |
| 190 | |
| 191 | /** Spans depth first, so the root comes first and every parent precedes its children. */ |
| 192 | export interface Trace { |
| 193 | id: string; |
| 194 | spans: Span[]; |
| 195 | } |
| 196 | |
| 197 | /** A trace as a list shows it, without the spans under its root. */ |
| 198 | export interface TraceSummary { |
| 199 | id: string; |
| 200 | root: Span; |
| 201 | spans: number; |
| 202 | services: string[]; |
| 203 | /** The first failed span's status message. */ |
| 204 | error: string | null; |
| 205 | } |
| 206 | |
| 207 | export interface LogLine { |
| 208 | t: number; |
| 209 | container: string; |
| 210 | stream: "stdout" | "stderr"; |
| 211 | level: "debug" | "info" | "warn" | "error" | null; |
| 212 | text: string; |
| 213 | } |
| 214 | |
| 215 | export interface HostInfo { |
| 216 | cores: number; |
| 217 | memory: number; |
| 218 | bootedAt: number; |
| 219 | /** |
| 220 | * Stretches the UPS ran on battery, oldest first, from upsmon's ONBATT and ONLINE events; `end` is null until mains |
| 221 | * returns. Null while no UPS is connected. |
| 222 | */ |
| 223 | outages: { start: number; end: number | null }[] | null; |
| 224 | } |
| 225 | |
| 226 | export interface Ups { |
| 227 | status: "online" | "battery" | "charging" | "unknown"; |
| 228 | /** Seconds of battery at the current load. */ |
| 229 | runtime: number; |
| 230 | /** Watts. */ |
| 231 | load: number; |
| 232 | /** Battery charge in percent. */ |
| 233 | charge: number; |
| 234 | } |
| 235 | |
| 236 | /** Seconds between ticks of the live stream. */ |
| 237 | export const LIVE_INTERVAL = 2; |
| 238 | |
| 239 | /** qBittorrent's `eta` when it has no estimate. */ |
| 240 | export const UNKNOWN_ETA = 8640000; |
| 241 | |
| 242 | /** One tick of the live stream; every value is the latest sample. */ |
| 243 | export interface Live { |
| 244 | t: number; |
| 245 | /** `cpu` in percent of the machine; `arc` is null without ZFS; `temperature` is the CPU package's in °C, null without a sensor. */ |
| 246 | host: { cpu: number; memory: number; arc: number | null; temperature: number | null }; |
| 247 | /** Empty while Nomad can't be reached. */ |
| 248 | services: Record<string, Pick<ServiceSummary, "cpu" | "memory" | "health">>; |
| 249 | /** Null while no UPS is connected. */ |
| 250 | ups: Ups | null; |
| 251 | } |
| 252 | |
| 253 | export const METRICS = [ |
| 254 | "host.cpu", "host.memory", "host.temperature", "host.power", "host.gpu", "host.network", |
| 255 | "service.cpu", "service.memory", "vm.cpu", "vm.memory", |
| 256 | ] as const; |
| 257 | export type Metric = (typeof METRICS)[number]; |
| 258 | |
| 259 | export const ACTIONS = ["restart", "stop", "start"] as const; |
| 260 | export type Action = (typeof ACTIONS)[number]; |