1import { A, useLocation, useMatch } from "@solidjs/router";
2import ArrowUpRight from "lucide-solid/icons/arrow-up-right";
3import Boxes from "lucide-solid/icons/boxes";
4import ChevronRight from "lucide-solid/icons/chevron-right";
5import ChevronsUpDown from "lucide-solid/icons/chevrons-up-down";
6import Clapperboard from "lucide-solid/icons/clapperboard";
7import Eye from "lucide-solid/icons/eye";
8import HardDrive from "lucide-solid/icons/hard-drive";
9import House from "lucide-solid/icons/house";
10import LogOut from "lucide-solid/icons/log-out";
11import Monitor from "lucide-solid/icons/monitor";
12import Rocket from "lucide-solid/icons/rocket";
13import SquarePlay from "lucide-solid/icons/square-play";
14import UserRound from "lucide-solid/icons/user-round";
15import Plug from "lucide-solid/icons/plug";
16import Users from "lucide-solid/icons/users";
17import { createSignal, For, type JSX, onCleanup, Show } from "solid-js";
18import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts";
19import { queries } from "../api.ts";
20import snowflake from "../snowflake.svg";
21import { bytes, cores, plural } from "../format.ts";
22import { signOut } from "../auth.ts";
23import { account, Avatar, displayName } from "../pages/Account.tsx";
24import { status } from "../pages/Overview.tsx";
25import { TABS as STORAGE_TABS } from "../pages/Storage.tsx";
26import { TABS as YOUTUBE_TABS } from "../pages/YouTube.tsx";
27import { stream } from "../live.ts";
28import { AppIcon } from "./AppIcon.tsx";
29import { lastGood, Loaded } from "./Loaded.tsx";
30import { PaperCloverMark } from "./PaperCloverMark.tsx";
31import { PirateFlag } from "./PirateFlag.tsx";
32import { Spark } from "./Spark.tsx";
33import { Status, StatusLabel } from "./Status.tsx";
34
35type Icon = (props: { class: string }) => JSX.Element;
36
37interface Page {
38 href: string;
39 label: string;
40 icon: Icon;
41 section: Section;
42 /** `?tab=` values, labels and icons, the page's default first. */
43 tabs?: readonly (readonly [string, string, Icon])[];
44}
45
46const BEFORE: Page[] = [{ href: "/", label: "overview", icon: House, section: "launcher" }];
47
48const AFTER: Page[] = [
49 { href: "/mcp", label: "ai / mcp", icon: Plug, section: "ai" },
50 {
51 href: "/storage", label: "storage", icon: HardDrive, section: "admin",
52 tabs: STORAGE_TABS,
53 },
54 { href: "/media", label: "media", icon: Clapperboard, section: "media" },
55 { href: "/seedbox", label: "seedbox", icon: PirateFlag, section: "media" },
56 {
57 href: "/youtube", label: "youtube", icon: SquarePlay, section: "media",
58 tabs: YOUTUBE_TABS,
59 },
60 { href: "/paper-clover", label: "paper clover", icon: PaperCloverMark, section: "admin" },
61 { href: "/users", label: "users", icon: Users, section: "admin" },
62 { href: "/deploys", label: "deploys", icon: Rocket, section: "admin" },
63 { href: "/vms", label: "vms", icon: Monitor, section: "vms" },
64];
65
66/** Every page and the section that opens it; admins also get the Services group between the two halves. */
67export const PAGES = [...BEFORE, ...AFTER];
68
69/** Groups an admin can preview the dashboard as. */
70const PREVIEW_GROUPS = ["media", "media-manage", "metrics", "vm", "ai"];
71
72/** How many apps need a look, on the overview's link, so it shows from every page. */
73function IssueCount() {
74 const issues = () => status.latest()?.issues ?? [];
75 return (
76 <Show when={issues().length}>
77 <span class="badge">
78 <span class="tally" data-tip={issues().map((issue) =>
79 `${issue.service.name} ${issue.health}`).join(", ")}>
80 <StatusLabel health={issues().some((issue) => issue.health === "down") ? "down" : "degraded"}>{issues().length}</StatusLabel>
81 <span class="sr-only">need a look</span>
82 </span>
83 </span>
84 </Show>
85 );
86}
87
88/** A group's open state, remembered across visits. */
89function remembered(key: string, initial: boolean) {
90 const [open, setOpen] = createSignal((localStorage.getItem(key) ?? (initial ? "open" : "closed")) === "open");
91 const toggle = () => {
92 setOpen(!open());
93 localStorage.setItem(key, open() ? "open" : "closed");
94 };
95 return [open, toggle] as const;
96}
97
98function NavLink(props: { page: Page; children?: JSX.Element }) {
99 const link = (
100 <A href={props.page.href} end class="nav-item" activeClass="active">
101 <props.page.icon class="icon" />
102 <span class="label">{props.page.label}</span>
103 {props.children}
104 </A>
105 );
106 return (
107 <Show when={props.page.tabs} fallback={link}>
108 {(tabs) => {
109 const [open, toggle] = remembered(`sidebar.${props.page.label}`, false);
110 const location = useLocation();
111 const current = (tab: string) => location.pathname === props.page.href
112 && (new URLSearchParams(location.search).get("tab") ?? tabs()[0]![0]) === tab;
113 return (
114 <>
115 <div class="nav-row">
116 {link}
117 <button class="nav-toggle" aria-expanded={open()} aria-label={`${props.page.label} tabs`} onClick={toggle}>
118 <ChevronRight class={`chevron ${open() ? "open" : ""}`} />
119 </button>
120 </div>
121 <div class="nav-group" classList={{ open: open() }} inert={!open()}>
122 <div>
123 <For each={tabs()}>
124 {([tab, label, Icon]) => (
125 <a href={tab ? `${props.page.href}?tab=${tab}` : props.page.href} class="nav-sub"
126 aria-current={current(tab) ? "page" : undefined}>
127 <Icon class="icon" />{label}
128 </a>
129 )}
130 </For>
131 </div>
132 </div>
133 </>
134 );
135 }}
136 </Show>
137 );
138}
139
140function VMNav() {
141 const location = useLocation();
142 const [open, toggle] = remembered("sidebar.vms", true);
143 const [data, { refetch }] = queries.vms.use(() => open() || location.pathname === "/vms" ? undefined : false);
144 const loaded = lastGood(data);
145 const timer = setInterval(() => open() && !data.loading && refetch(), 15_000);
146 onCleanup(() => clearInterval(timer));
147 const selected = () => location.pathname === "/vms" ? new URLSearchParams(location.search).get("vm") : null;
148 return <>
149 <div class="nav-row">
150 <a href="/vms" class="nav-item" aria-current={location.pathname === "/vms" && !selected() ? "page" : undefined} classList={{ active: location.pathname === "/vms" && !selected() }}>
151 <Monitor class="icon" /><span class="label">vms</span>
152 </a>
153 <button class="nav-toggle" aria-expanded={open()} aria-label="Virtual machines" onClick={toggle}>
154 <ChevronRight class={`chevron ${open() ? "open" : ""}`} />
155 </button>
156 </div>
157 <div class="nav-group" classList={{ open: open() }} inert={!open()}><div>
158 <For each={loaded()?.domains}>{(vm) => <a href={`/vms?vm=${encodeURIComponent(vm.name)}`} class="nav-sub"
159 aria-current={selected() === vm.name ? "page" : undefined}>
160 <Monitor class="icon" /><span class="label">{vm.name}</span><Status health={vm.state === "running" ? "healthy" : vm.state === "crashed" ? "down" : "stopped"} />
161 </a>}</For>
162 <Show when={data.error && !loaded()}><span class="nav-sub muted">VMs unavailable</span></Show>
163 </div></div>
164 </>;
165}
166
167/** Health states counted together in the collapsed group's summary, in order. */
168const TALLY: [Health, Health[], string][] = [
169 ["healthy", ["healthy"], "up"],
170 ["down", ["down"], "down"],
171 ["degraded", ["degraded"], "degraded"],
172 ["deploying", ["deploying", "restarting", "starting"], "changing"],
173 ["stopped", ["stopped"], "stopped"],
174];
175
176function Services() {
177 const [open, toggle] = remembered("sidebar.services", true);
178 const [services, { refetch }] = queries.services.use();
179 stream.start();
180
181 const health = (id: string, fallback: Health) => stream.latest()?.services[id]?.health ?? fallback;
182 const loaded = lastGood(services);
183 const tally = () => TALLY.map(([shape, states, word]) => {
184 const names = (loaded() ?? []).filter((service) => states.includes(health(service.id, service.health))).map((s) => s.name);
185 return { shape, word, names };
186 }).filter((group) => group.names.length);
187
188 return (
189 <>
190 <button class="nav-item" onClick={toggle} aria-expanded={open()}>
191 <Boxes class="icon" />
192 <span class="label">services</span>
193 <span class="badge">
194 <For each={tally()}>
195 {(group) => (
196 <span class="tally" data-tip={group.shape === "healthy" ? undefined : group.names.join(", ")}>
197 <StatusLabel health={group.shape}>
198 {group.names.length}<Show when={group.shape === "healthy"}> {group.word}</Show>
199 </StatusLabel>
200 <Show when={group.shape !== "healthy"}><span class="sr-only">{group.word}</span></Show>
201 </span>
202 )}
203 </For>
204 </span>
205 <ChevronRight class={`chevron ${open() ? "open" : ""}`} />
206 </button>
207 <div class="nav-group" classList={{ open: open(), stale: !stream.live() }} inert={!open()}>
208 <div>
209 <Loaded data={services} what="services" retry={refetch} skeleton={
210 <For each={Array(8)}>{() => <div class="service-row"><span /><span class="skeleton" /><span class="skeleton" /></div>}</For>
211 }>
212 {(all) => (
213 <For each={all()}>
214 {(service) => {
215 const trail = () => stream.trails()[service.id];
216 const sample = () => stream.latest()?.services[service.id];
217 const cpu = () => sample()?.cpu ?? service.cpu;
218 const memory = () => sample()?.memory ?? service.memory;
219 return (
220 <A href={`/services/${service.id}`} class="service-row" activeClass="active">
221 <Status health={health(service.id, service.health)} />
222 <AppIcon id={service.id} name={service.name} icon={service.icon} />
223 <span class="name">{service.name}</span>
224 <Spark values={trail()?.cpu ?? []} count={stream.count()} max={service.cpuLimit / 10} color="var(--series-1)"
225 label={`cpu ${cpu() === null ? "not measured" : cores(cpu()!)} of ${plural(service.cpuLimit, "core")}`} />
226 <Spark values={trail()?.memory ?? []} count={stream.count()} max={service.memoryLimit} color="var(--series-2)"
227 label={`memory ${memory() === null ? "not measured" : bytes(memory()!)} of ${bytes(service.memoryLimit)}`} />
228 <Show when={service.url} fallback={<span />}>
229 {(url) => (
230 <a class="row-icon" href={url()} target="_blank" rel="noreferrer" aria-label={`Open ${service.name}`}
231 data-tip={new URL(url()).host} onClick={(event) => event.stopPropagation()}>
232 <ArrowUpRight size={13} />
233 </a>
234 )}
235 </Show>
236 </A>
237 );
238 }}
239 </For>
240 )}
241 </Loaded>
242 </div>
243 </div>
244 </>
245 );
246}
247
248/** Previews the dashboard as someone in fewer groups; the server only honors it for admins. */
249export function viewAs(groups: string[] | null) {
250 document.cookie = groups ? `${VIEW_AS}=${encodeURIComponent(groups.join(","))}; path=/; samesite=strict` : `${VIEW_AS}=; path=/; max-age=0`;
251 location.reload();
252}
253
254function Whoami(props: { me: Me }) {
255 const user = lastGood(account);
256 const name = () => {
257 const known = user();
258 return known ? displayName(known) : account.error ? props.me.name : "";
259 };
260 const here = useMatch(() => "/account");
261 const [picked, setPicked] = createSignal(props.me.viewing ? props.me.groups : []);
262 let menu!: HTMLDivElement;
263 return (
264 <div class="whoami">
265 <button class="whoami-button" classList={{ active: !!here() }} popovertarget="account-menu">
266 <Avatar picture={user()?.picture ?? null} name={name()} />
267 <span class="name"><Show when={user()} fallback={account.error ? name() : <span class="skeleton" style={{ width: "100px" }} />}>
268 {(profile) => displayName(profile())}
269 </Show></span>
270 <ChevronsUpDown size={14} class="icon" />
271 </button>
272 <div ref={menu} id="account-menu" popover class="account-menu">
273 <A href="/account" class="nav-item" onClick={() => menu.hidePopover()}><UserRound class="icon" /><span class="label">profile</span></A>
274 <button class="nav-item" onClick={signOut}><LogOut class="icon" /><span class="label">sign out</span></button>
275 <Show when={props.me.viewing || props.me.sections.includes("admin")}>
276 <form class="view-as" onSubmit={(event) => {
277 event.preventDefault();
278 viewAs(picked());
279 }}>
280 <span class="muted">view as someone in</span>
281 <div class="chips">
282 <For each={PREVIEW_GROUPS}>
283 {(group) => (
284 <button type="button" class="chip" aria-pressed={picked().includes(group)}
285 onClick={() => setPicked(picked().includes(group) ? picked().filter((g) => g !== group) : [...picked(), group])}>
286 {group}
287 </button>
288 )}
289 </For>
290 </div>
291 <button class="button small"><Eye size={14} />{picked().length ? "preview" : "preview as friend"}</button>
292 </form>
293 </Show>
294 </div>
295 </div>
296 );
297}
298
299export function Sidebar(props: { me: Me }) {
300 const allowed = (page: Page) => props.me.sections.includes(page.section);
301 return (
302 <nav class="sidebar" aria-label="Main">
303 <div class="brand">
304 <img src={snowflake} alt="" />
305 the snow globe
306 </div>
307 <div class="nav">
308 <For each={BEFORE.filter(allowed)}>{(page) => <NavLink page={page}><IssueCount /></NavLink>}</For>
309 <Show when={props.me.sections.includes("admin")}>
310 <Services />
311 </Show>
312 <For each={AFTER.filter(allowed)}>{(page) => page.section === "vms" ? <VMNav /> : <NavLink page={page} />}</For>
313 </div>
314 <Whoami me={props.me} />
315 </nav>
316 );
317}