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