Files
nxdns/admin/src/shell/AppShell.tsx
T
mokhtar 22abcd9b7b release: nix flake with tag-pinned hashes, reproducible tarballs (milestone 40)
flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded.

The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
2026-09-08 21:45:22 +02:00

403 lines
11 KiB
TypeScript

import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { Button, Disclosure, DisclosurePanel } from "react-aria-components";
import { useAuth } from "@/auth/store";
import InlineError from "@/lib/InlineError";
import { healthQuery, versionQuery } from "@/lib/queries";
import PauseControl from "@/features/pause/PauseControl";
import { diagnosticsBadge } from "./diagnosticsBadge";
import ConfigStatusNotices from "./ConfigStatusNotices";
import SidebarStatus, { ProtectionDot } from "./SidebarStatus";
import AuthorityLine from "@/features/configuration/AuthorityLine";
import { styles as shared } from "@/ui/styles";
import { colors, layers, metrics } from "@/ui/tokens.stylex";
/** The one breakpoint the shell has: below it the sidebar becomes a drawer. */
const WIDE = "@media (min-width: 801px)";
/**
* Two labelled groups (ui-visual-redesign.md): MONITORING is what the resolver
* is doing, SYSTEM is what it is told to do and how the box is faring. The
* three configuration pages sit flat in SYSTEM — "Configuration" is not one
* page — with the authority line under them.
*/
const MONITORING_ITEMS = [
{ to: "/overview", label: "Overview" },
{ to: "/activity", label: "Activity" },
{ to: "/clients", label: "Clients" },
] as const;
const SYSTEM_ITEMS = [
{ to: "/configuration/protection", label: "Protection" },
{ to: "/configuration/resolution", label: "Resolution" },
{ to: "/configuration/system", label: "System" },
{ to: "/diagnostics", label: "Diagnostics" },
] as const;
const styles = stylex.create({
navList: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
},
navLink: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
minHeight: metrics.hitTarget,
borderRadius: metrics.radius,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
// Carried by every item, active or not: a weight that changes on
// navigation would reflow the whole nav list.
fontWeight: 500,
textDecorationLine: "none",
},
navLabel: {
flex: 1,
},
navGroup: {
marginTop: "1.125rem",
},
navGroupLabel: {
display: "block",
paddingInline: "0.75rem",
paddingBottom: "0.375rem",
fontSize: "0.6875rem",
lineHeight: "1rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.09em",
color: colors.textMuted,
},
/**
* Neutral chrome: the mark is the message, and a coloured pill here would be
* the page's loudest element on every route. Text and shape carry it.
*/
badge: {
minWidth: "1.25rem",
borderRadius: "0.625rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceHover,
paddingInline: "0.375rem",
fontSize: "0.75rem",
lineHeight: "1.125rem",
fontWeight: 500,
textAlign: "center",
color: colors.textSecondary,
},
/** The control sits with the footer, not in the scrolling nav list above it. */
sidebarFooter: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
},
/** The current page is a soft accent-tinted block, in the accent's own colour. */
navActive: {
backgroundColor: colors.primarySurface,
color: colors.primaryOnSurface,
},
navIdle: {
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
color: { default: colors.textSecondary, ":hover": colors.text },
transitionProperty: metrics.transitionProperty,
transitionDuration: { default: metrics.transitionDuration, "@media (prefers-reduced-motion: reduce)": "0s" },
},
versionFooter: {
paddingInline: "1rem",
paddingBlock: "0.75rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
logoutRow: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
/**
* In a 14rem column the button and its error stack, so the button spans the
* sidebar and matches the Pause trigger below it.
*/
sidebarLogout: {
flexDirection: "column",
alignItems: "stretch",
gap: "0.25rem",
paddingInline: "1rem",
paddingTop: "0.75rem",
},
shell: {
minHeight: "100dvh",
height: { default: null, [WIDE]: "100dvh" },
overflow: { default: null, [WIDE]: "hidden" },
backgroundColor: colors.surface,
color: colors.text,
display: { default: "block", [WIDE]: "grid" },
gridTemplateColumns: { default: null, [WIDE]: "14rem 1fr" },
},
sidebar: {
display: { default: "none", [WIDE]: "flex" },
flexDirection: { default: null, [WIDE]: "column" },
minHeight: { default: null, [WIDE]: 0 },
overflow: { default: null, [WIDE]: "hidden" },
backgroundColor: colors.surfaceRaised,
borderRightWidth: 1,
borderRightStyle: "solid",
borderRightColor: colors.border,
},
brand: {
paddingInline: "1rem",
paddingBlock: "1.25rem",
fontSize: "1rem",
lineHeight: "1.5rem",
fontWeight: 650,
letterSpacing: "-0.015em",
},
sidebarNav: {
flex: 1,
minHeight: { default: null, [WIDE]: 0 },
overflowY: { default: null, [WIDE]: "auto" },
paddingInline: "0.5rem",
},
column: {
display: "flex",
minHeight: { default: "100dvh", [WIDE]: 0 },
minWidth: { default: null, [WIDE]: 0 },
flexDirection: "column",
},
/**
* Narrow only: on WIDE the sidebar carries everything this row held. Sticky,
* so the brand, the protection state and the menu stay in reach down a long
* page.
*/
header: {
display: { default: "flex", [WIDE]: "none" },
position: "sticky",
top: 0,
zIndex: layers.tooltip,
alignItems: "center",
gap: "0.75rem",
backgroundColor: colors.surfaceRaised,
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
paddingInline: "1rem",
paddingBlock: "0.5rem",
},
narrowOnly: {
display: { default: null, [WIDE]: "none" },
},
narrowBrand: {
fontSize: "1rem",
lineHeight: "1.5rem",
fontWeight: 650,
letterSpacing: "-0.015em",
display: { default: null, [WIDE]: "none" },
},
headerRight: {
marginLeft: "auto",
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
drawer: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
display: { default: null, [WIDE]: "none" },
},
drawerNav: {
paddingInline: "0.5rem",
paddingBlock: "0.5rem",
},
main: {
flex: 1,
minHeight: { default: null, [WIDE]: 0 },
minWidth: { default: null, [WIDE]: 0 },
overflowY: { default: null, [WIDE]: "auto" },
padding: "1rem",
},
});
function NavItem({
to,
label,
onNavigate,
badge,
}: {
to: string;
label: string;
onNavigate?: () => void;
badge?: { text: string; label: string } | null;
}) {
return (
<li>
<Link
to={to}
onClick={onNavigate}
activeProps={{
"aria-current": "page",
className: stylex.props(styles.navActive).className,
}}
inactiveProps={{ className: stylex.props(styles.navIdle).className }}
{...stylex.props(styles.navLink, shared.focusRing)}
>
<span {...stylex.props(styles.navLabel)}>{label}</span>
{badge !== undefined && badge !== null && (
<span aria-label={badge.label} {...stylex.props(styles.badge)}>
{badge.text}
</span>
)}
</Link>
</li>
);
}
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const health = useQuery(healthQuery());
const badge = diagnosticsBadge(health.data, health.isError);
const monitoringId = useId();
const systemId = useId();
return (
<>
<div>
{/* A span, not a heading: the sidebar label is not a section of the
page, and an h2 here lands in the middle of the page's own outline. */}
<span id={monitoringId} {...stylex.props(styles.navGroupLabel)}>
Monitoring
</span>
<ul aria-labelledby={monitoringId} {...stylex.props(styles.navList)}>
{MONITORING_ITEMS.map((item) => (
<NavItem key={item.to} to={item.to} label={item.label} onNavigate={onNavigate} />
))}
</ul>
</div>
<div {...stylex.props(styles.navGroup)}>
<span id={systemId} {...stylex.props(styles.navGroupLabel)}>
System
</span>
<ul aria-labelledby={systemId} {...stylex.props(styles.navList)}>
{SYSTEM_ITEMS.map((item) => (
<NavItem
key={item.to}
to={item.to}
label={item.label}
onNavigate={onNavigate}
badge={item.to === "/diagnostics" ? badge : undefined}
/>
))}
</ul>
<AuthorityLine />
</div>
</>
);
}
/**
* The sidebar's foot, in both renderings. Pause is a runtime action on the whole
* resolver rather than on the page in front of the reader, which is why it sits
* with the status block and the version label instead of in the header of every
* route.
*/
function SidebarFooter() {
return (
<>
<div {...stylex.props(styles.sidebarFooter)}>
<PauseControl />
</div>
<SidebarStatus />
<VersionFooter />
</>
);
}
function VersionFooter() {
const { data } = useQuery(versionQuery());
return (
<footer {...stylex.props(styles.versionFooter)}>
{data === undefined ? "nxdns" : `nxdns v${data.version}`}
</footer>
);
}
function LogoutButton({ style }: { style?: stylex.StyleXStyles }) {
const { authRequired, logout } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState<unknown>(null);
if (authRequired !== true) return null;
return (
<div {...stylex.props(styles.logoutRow, style)}>
<button
type="button"
onClick={() => {
setError(null);
void logout().then(
() => navigate({ to: "/login" }),
(logoutError: unknown) => setError(logoutError),
);
}}
{...stylex.props(shared.button, shared.focusRing)}
>
Log out
</button>
{error !== null && <InlineError error={error} />}
</div>
);
}
export default function AppShell() {
const [drawerOpen, setDrawerOpen] = useState(false);
return (
<div {...stylex.props(styles.shell)}>
<aside {...stylex.props(styles.sidebar)}>
<div {...stylex.props(styles.brand)}>nxdns</div>
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
<NavLinks />
</nav>
<LogoutButton style={styles.sidebarLogout} />
<SidebarFooter />
</aside>
{/* The column itself is the disclosure: the trigger sits in the header
and the panel opens below the notices, so any wrapper around only
the two would have to cut across the column's own flex children. */}
<Disclosure isExpanded={drawerOpen} onExpandedChange={setDrawerOpen} {...stylex.props(styles.column)}>
<header {...stylex.props(styles.header)}>
<Button
slot="trigger"
className={() =>
stylex.props(shared.button, styles.narrowOnly, shared.focusRing).className ?? ""
}
>
Menu
</Button>
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
<div {...stylex.props(styles.headerRight)}>
<ProtectionDot />
<LogoutButton />
</div>
</header>
<ConfigStatusNotices />
<DisclosurePanel id="mobile-nav" {...stylex.props(styles.drawer)}>
{/* React Aria only hides a collapsed panel; the shell drops it
instead, so a closed drawer keeps no second health poll and
no second Pause control alive behind the header. */}
{drawerOpen && (
<>
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
<NavLinks onNavigate={() => setDrawerOpen(false)} />
</nav>
<SidebarFooter />
</>
)}
</DisclosurePanel>
<main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}>
<Outlet />
</main>
</Disclosure>
</div>
);
}