feat(web): add settings sub-navigation with Live Events

Wraps /settings in a left-sidebar layout. Existing settings page becomes
the index; adds a placeholder /settings/live-events. The layout owns the
section h1 and hides the app-shell header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-10 12:20:42 -04:00
parent 7b832c74a8
commit a74ae2e7f8
No known key found for this signature in database
4 changed files with 299 additions and 217 deletions

View file

@ -28,6 +28,8 @@ import * as Insights from "./routes/insights";
import * as InsightsEditor from "./routes/insights-editor";
import * as InsightsNew from "./routes/insights-new";
import * as Settings from "./routes/settings";
import * as SettingsOverview from "./routes/settings-overview";
import * as SettingsLiveEvents from "./routes/settings-live-events";
import * as Profile from "./routes/profile";
import AppShellModule from "./layouts/app-shell";
@ -113,7 +115,12 @@ export const routes: RouteObject[] = [
route("new", InsightsNew),
],
}),
route("settings", Settings),
route("settings", Settings, {
children: [
indexRoute(SettingsOverview),
route("live-events", SettingsLiveEvents),
],
}),
route("profile", Profile),
],
},

View file

@ -0,0 +1,3 @@
export default function SettingsLiveEvents() {
return <div />;
}

View file

@ -0,0 +1,221 @@
import { useState } from "react";
import type {
ServerListenSettings,
ServerSettings,
} from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import { CollapsibleFile } from "../components/collapsible-file";
import {
Badge,
Count,
Mono,
Muted,
NumberValue,
ObjectStoreValue,
Panel,
PanelSkeleton,
Row,
type SettingsView,
Toggle,
UrlValue,
UsernameList,
ViewToggle,
plural,
} from "../components/settings-panel";
export default function SettingsOverview() {
const settingsQuery = useServerSettings();
const settings = settingsQuery.data;
const [view, setView] = useState<SettingsView>("settings");
if (!settings) {
return (
<div className="space-y-6">
<PageIntro view={view} setView={setView} />
<PanelSkeleton />
<PanelSkeleton />
<PanelSkeleton />
<PanelSkeleton />
</div>
);
}
return (
<div className="space-y-6">
<PageIntro view={view} setView={setView} />
{view === "settings" ? (
<>
<ServerPanel settings={settings} />
<DataPanel settings={settings} />
<SecurityPanel settings={settings} />
<IntegrationsPanel settings={settings} />
</>
) : (
<CollapsibleFile
file={{
name: "server-settings.json",
contents: JSON.stringify(settings, null, 2),
lang: "json",
}}
/>
)}
</div>
);
}
function PageIntro({
view,
setView,
}: {
view: SettingsView;
setView: (v: SettingsView) => void;
}) {
return (
<div className="flex items-start justify-between gap-6">
<p className="max-w-[64ch] text-sm/6 text-fg-3 text-pretty">
Snapshot of the server configuration. Edit via{" "}
<code className="font-mono text-fg-2">settings.toml</code>; changes take
effect on the next server restart.
</p>
<ViewToggle view={view} setView={setView} />
</div>
);
}
function ServerPanel({ settings }: { settings: ServerSettings }) {
const { listen, web, api, scheduler } = settings.server;
return (
<Panel title="Server">
<Row title="Web URL" help="Public URL for the browser UI.">
{web.enabled ? <UrlValue url={web.url} /> : <Toggle on={false} />}
</Row>
<Row title="API URL" help="Base URL advertised to API clients.">
{api.url ? <UrlValue url={api.url} /> : <Muted>Same origin</Muted>}
</Row>
<Row title="Listen" help="Address the API server is bound to.">
<ListenValue listen={listen} />
</Row>
<Row title="Max concurrent runs" help="Scheduler ceiling on simultaneous runs.">
<NumberValue value={scheduler.max_concurrent_runs} />
</Row>
</Panel>
);
}
function DataPanel({ settings }: { settings: ServerSettings }) {
const { storage, slatedb, artifacts } = settings.server;
return (
<Panel title="Data">
<Row title="Storage root" help="Filesystem path for run state and logs.">
<Mono>{storage.root}</Mono>
</Row>
<Row title="SlateDB" help="Object store backing the embedded database.">
<ObjectStoreValue store={slatedb.store} prefix={slatedb.prefix} />
</Row>
<Row title="Artifacts" help="Where run artifacts are persisted.">
<ObjectStoreValue store={artifacts.store} prefix={artifacts.prefix} />
</Row>
</Panel>
);
}
function SecurityPanel({ settings }: { settings: ServerSettings }) {
const { auth, ip_allowlist } = settings.server;
const githubUsers = auth.github.allowed_usernames;
return (
<Panel title="Security">
<Row title="Auth methods" help="How users may sign in to this server.">
{auth.methods.length === 0 ? (
<Muted>None configured</Muted>
) : (
<div className="flex flex-wrap gap-1.5">
{auth.methods.map((m) => (
<Badge key={m}>{m}</Badge>
))}
</div>
)}
</Row>
<Row
title="Allowed usernames"
help="GitHub usernames permitted to authenticate."
>
{githubUsers.length === 0 ? (
<Muted>Anyone</Muted>
) : (
<UsernameList names={githubUsers} />
)}
</Row>
<Row title="IP allowlist" help="Network sources permitted to reach the API.">
<Count
n={ip_allowlist.entries.length}
singular="entry"
plural="entries"
suffix={
ip_allowlist.trusted_proxy_count > 0
? `· ${ip_allowlist.trusted_proxy_count} trusted ${plural(ip_allowlist.trusted_proxy_count, "proxy", "proxies")}`
: undefined
}
/>
</Row>
</Panel>
);
}
function IntegrationsPanel({ settings }: { settings: ServerSettings }) {
const { integrations } = settings.server;
return (
<Panel title="Integrations">
<Row title="GitHub" help="App for repo access, checks, and PR automation.">
<IntegrationValue
enabled={integrations.github.enabled}
detail={
integrations.github.slug
? `app: ${integrations.github.slug}`
: integrations.github.app_id
? `app id: ${integrations.github.app_id}`
: undefined
}
/>
</Row>
</Panel>
);
}
function ListenValue({ listen }: { listen: ServerListenSettings }) {
if (listen.type === "tcp") {
return (
<span className="inline-flex items-center gap-2">
<Badge>tcp</Badge>
<span className="truncate font-mono text-xs text-fg-2" title={listen.address}>
{listen.address}
</span>
</span>
);
}
return (
<span className="inline-flex items-center gap-2">
<Badge>unix</Badge>
<span className="truncate font-mono text-xs text-fg-2" title={listen.path}>
{listen.path}
</span>
</span>
);
}
function IntegrationValue({
enabled,
detail,
}: {
enabled: boolean;
detail?: string;
}) {
if (!enabled) return <Toggle on={false} />;
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<Toggle on={true} />
{detail ? (
<span className="font-mono text-xs text-fg-3">{detail}</span>
) : null}
</span>
);
}

View file

@ -1,226 +1,77 @@
import { useState } from "react";
import type {
ServerListenSettings,
ServerSettings,
} from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import { CollapsibleFile } from "../components/collapsible-file";
import {
Badge,
Count,
Mono,
Muted,
NumberValue,
ObjectStoreValue,
Panel,
PanelSkeleton,
Row,
type SettingsView,
Toggle,
UrlValue,
UsernameList,
ViewToggle,
plural,
} from "../components/settings-panel";
import { BoltIcon, Cog6ToothIcon } from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation } from "react-router";
export function meta({}: any) {
return [{ title: "Settings — Fabro" }];
}
export default function Settings() {
const settingsQuery = useServerSettings();
const settings = settingsQuery.data;
const [view, setView] = useState<SettingsView>("settings");
export const handle = { hideHeader: true };
if (!settings) {
return (
<div className="space-y-6">
<PageIntro view={view} setView={setView} />
<PanelSkeleton />
<PanelSkeleton />
<PanelSkeleton />
<PanelSkeleton />
type NavItem = {
name: string;
href: string;
icon: typeof Cog6ToothIcon;
match: (pathname: string) => boolean;
};
const navItems: NavItem[] = [
{
name: "Settings",
href: "/settings",
icon: Cog6ToothIcon,
match: (p) => p === "/settings",
},
{
name: "Live Events",
href: "/settings/live-events",
icon: BoltIcon,
match: (p) => p.startsWith("/settings/live-events"),
},
];
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
export default function SettingsLayout() {
const { pathname } = useLocation();
const currentName = navItems.find((item) => item.match(pathname))?.name ?? "Settings";
return (
<div className="flex flex-col gap-6 lg:flex-row">
<aside className="lg:w-56 lg:shrink-0">
<nav className="sticky top-6">
<ul role="list" className="flex gap-1 overflow-x-auto lg:flex-col lg:gap-0.5">
{navItems.map((item) => {
const current = item.match(pathname);
return (
<li key={item.name}>
<Link
to={item.href}
aria-current={current ? "page" : undefined}
className={classNames(
"flex items-center gap-2 rounded-md px-2.5 py-2 text-sm whitespace-nowrap transition-colors",
current
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg",
)}
>
<item.icon className="size-4 shrink-0" aria-hidden="true" />
{item.name}
</Link>
</li>
);
})}
</ul>
</nav>
</aside>
<div className="min-w-0 flex-1">
<h1 className="mb-2 text-xl font-semibold tracking-tight text-fg">
{currentName}
</h1>
<Outlet />
</div>
);
}
return (
<div className="space-y-6">
<PageIntro view={view} setView={setView} />
{view === "settings" ? (
<>
<ServerPanel settings={settings} />
<DataPanel settings={settings} />
<SecurityPanel settings={settings} />
<IntegrationsPanel settings={settings} />
</>
) : (
<CollapsibleFile
file={{
name: "server-settings.json",
contents: JSON.stringify(settings, null, 2),
lang: "json",
}}
/>
)}
</div>
);
}
function PageIntro({
view,
setView,
}: {
view: SettingsView;
setView: (v: SettingsView) => void;
}) {
return (
<div className="flex items-start justify-between gap-6">
<p className="max-w-[64ch] text-sm/6 text-fg-3 text-pretty">
Snapshot of the server configuration. Edit via{" "}
<code className="font-mono text-fg-2">settings.toml</code>; changes take
effect on the next server restart.
</p>
<ViewToggle view={view} setView={setView} />
</div>
);
}
function ServerPanel({ settings }: { settings: ServerSettings }) {
const { listen, web, api, scheduler } = settings.server;
return (
<Panel title="Server">
<Row title="Web URL" help="Public URL for the browser UI.">
{web.enabled ? <UrlValue url={web.url} /> : <Toggle on={false} />}
</Row>
<Row title="API URL" help="Base URL advertised to API clients.">
{api.url ? <UrlValue url={api.url} /> : <Muted>Same origin</Muted>}
</Row>
<Row title="Listen" help="Address the API server is bound to.">
<ListenValue listen={listen} />
</Row>
<Row title="Max concurrent runs" help="Scheduler ceiling on simultaneous runs.">
<NumberValue value={scheduler.max_concurrent_runs} />
</Row>
</Panel>
);
}
function DataPanel({ settings }: { settings: ServerSettings }) {
const { storage, slatedb, artifacts } = settings.server;
return (
<Panel title="Data">
<Row title="Storage root" help="Filesystem path for run state and logs.">
<Mono>{storage.root}</Mono>
</Row>
<Row title="SlateDB" help="Object store backing the embedded database.">
<ObjectStoreValue store={slatedb.store} prefix={slatedb.prefix} />
</Row>
<Row title="Artifacts" help="Where run artifacts are persisted.">
<ObjectStoreValue store={artifacts.store} prefix={artifacts.prefix} />
</Row>
</Panel>
);
}
function SecurityPanel({ settings }: { settings: ServerSettings }) {
const { auth, ip_allowlist } = settings.server;
const githubUsers = auth.github.allowed_usernames;
return (
<Panel title="Security">
<Row title="Auth methods" help="How users may sign in to this server.">
{auth.methods.length === 0 ? (
<Muted>None configured</Muted>
) : (
<div className="flex flex-wrap gap-1.5">
{auth.methods.map((m) => (
<Badge key={m}>{m}</Badge>
))}
</div>
)}
</Row>
<Row
title="Allowed usernames"
help="GitHub usernames permitted to authenticate."
>
{githubUsers.length === 0 ? (
<Muted>Anyone</Muted>
) : (
<UsernameList names={githubUsers} />
)}
</Row>
<Row title="IP allowlist" help="Network sources permitted to reach the API.">
<Count
n={ip_allowlist.entries.length}
singular="entry"
plural="entries"
suffix={
ip_allowlist.trusted_proxy_count > 0
? `· ${ip_allowlist.trusted_proxy_count} trusted ${plural(ip_allowlist.trusted_proxy_count, "proxy", "proxies")}`
: undefined
}
/>
</Row>
</Panel>
);
}
function IntegrationsPanel({ settings }: { settings: ServerSettings }) {
const { integrations } = settings.server;
return (
<Panel title="Integrations">
<Row title="GitHub" help="App for repo access, checks, and PR automation.">
<IntegrationValue
enabled={integrations.github.enabled}
detail={
integrations.github.slug
? `app: ${integrations.github.slug}`
: integrations.github.app_id
? `app id: ${integrations.github.app_id}`
: undefined
}
/>
</Row>
</Panel>
);
}
function ListenValue({ listen }: { listen: ServerListenSettings }) {
if (listen.type === "tcp") {
return (
<span className="inline-flex items-center gap-2">
<Badge>tcp</Badge>
<span className="truncate font-mono text-xs text-fg-2" title={listen.address}>
{listen.address}
</span>
</span>
);
}
return (
<span className="inline-flex items-center gap-2">
<Badge>unix</Badge>
<span className="truncate font-mono text-xs text-fg-2" title={listen.path}>
{listen.path}
</span>
</span>
);
}
function IntegrationValue({
enabled,
detail,
}: {
enabled: boolean;
detail?: string;
}) {
if (!enabled) return <Toggle on={false} />;
return (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<Toggle on={true} />
{detail ? (
<span className="font-mono text-xs text-fg-3">{detail}</span>
) : null}
</span>
);
}