| 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, 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 { account, Avatar, displayName } from "../pages/Account.tsx"; |
| 23 | import { status } from "../pages/Overview.tsx"; |
| 24 | import { TABS as STORAGE_TABS } from "../pages/Storage.tsx"; |
| 25 | import { TABS as YOUTUBE_TABS } from "../pages/YouTube.tsx"; |
| 26 | import { stream } from "../live.ts"; |
| 27 | import { AppIcon } from "./AppIcon.tsx"; |
| 28 | import { lastGood, Loaded } from "./Loaded.tsx"; |
| 29 | import { PaperCloverMark } from "./PaperCloverMark.tsx"; |
| 30 | import { PirateFlag } from "./PirateFlag.tsx"; |
| 31 | import { Spark } from "./Spark.tsx"; |
| 32 | import { Status, StatusLabel } from "./Status.tsx"; |
| 33 | |
| 34 | type Icon = (props: { class: string }) => JSX.Element; |
| 35 | |
| 36 | interface 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 | |
| 45 | const BEFORE: Page[] = [{ href: "/", label: "overview", icon: House, section: "launcher" }]; |
| 46 | |
| 47 | const 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. */ |
| 66 | export const PAGES = [...BEFORE, ...AFTER]; |
| 67 | |
| 68 | /** Groups an admin can preview the dashboard as. */ |
| 69 | const 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. */ |
| 72 | function 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. */ |
| 88 | function 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 | |
| 97 | function 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. */ |
| 140 | const 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 | |
| 148 | function 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. */ |
| 221 | export 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 | |
| 226 | function 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 | |
| 271 | export 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 | } |