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