| 1 | import { A, useLocation, useMatch } from "@solidjs/router"; |
| 2 | import ArrowUpRight from "lucide-solid/icons/arrow-up-right"; |
| 3 | import Boxes from "lucide-solid/icons/boxes"; |
| 4 | import ChevronRight from "lucide-solid/icons/chevron-right"; |
| 5 | import ChevronsUpDown from "lucide-solid/icons/chevrons-up-down"; |
| 6 | import Clapperboard from "lucide-solid/icons/clapperboard"; |
| 7 | import Eye from "lucide-solid/icons/eye"; |
| 8 | import HardDrive from "lucide-solid/icons/hard-drive"; |
| 9 | import House from "lucide-solid/icons/house"; |
| 10 | import LogOut from "lucide-solid/icons/log-out"; |
| 11 | import Monitor from "lucide-solid/icons/monitor"; |
| 12 | import Rocket from "lucide-solid/icons/rocket"; |
| 13 | import SquarePlay from "lucide-solid/icons/square-play"; |
| 14 | import UserRound from "lucide-solid/icons/user-round"; |
| 15 | import Plug from "lucide-solid/icons/plug"; |
| 16 | import Users from "lucide-solid/icons/users"; |
| 17 | import { createSignal, For, type JSX, onCleanup, Show } from "solid-js"; |
| 18 | import { type Health, type Me, type Section, VIEW_AS } from "../types/model.ts"; |
| 19 | import { queries } from "../api.ts"; |
| 20 | import snowflake from "../snowflake.svg"; |
| 21 | import { bytes, cores, plural } from "../format.ts"; |
| 22 | import { signOut } from "../auth.ts"; |
| 23 | import { account, Avatar, displayName } from "../pages/Account.tsx"; |
| 24 | import { status } from "../pages/Overview.tsx"; |
| 25 | import { TABS as STORAGE_TABS } from "../pages/Storage.tsx"; |
| 26 | import { TABS as YOUTUBE_TABS } from "../pages/YouTube.tsx"; |
| 27 | import { stream } from "../live.ts"; |
| 28 | import { AppIcon } from "./AppIcon.tsx"; |
| 29 | import { lastGood, Loaded } from "./Loaded.tsx"; |
| 30 | import { PaperCloverMark } from "./PaperCloverMark.tsx"; |
| 31 | import { PirateFlag } from "./PirateFlag.tsx"; |
| 32 | import { Spark } from "./Spark.tsx"; |
| 33 | import { Status, StatusLabel } from "./Status.tsx"; |
| 34 | |
| 35 | type Icon = (props: { class: string }) => JSX.Element; |
| 36 | |
| 37 | interface 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 | |
| 46 | const BEFORE: Page[] = [{ href: "/", label: "overview", icon: House, section: "launcher" }]; |
| 47 | |
| 48 | const 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. */ |
| 67 | export const PAGES = [...BEFORE, ...AFTER]; |
| 68 | |
| 69 | /** Groups an admin can preview the dashboard as. */ |
| 70 | const 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. */ |
| 73 | function 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. */ |
| 89 | function 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 | |
| 98 | function 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 | |
| 140 | function 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. */ |
| 168 | const 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 | |
| 176 | function 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. */ |
| 249 | export 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 | |
| 254 | function 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 | |
| 299 | export 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 | } |