feat(web): split settings into General/Integrations/Security/Storage tabs
Some checks failed
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled

Replaces the single Settings overview with four focused tabs, plus
the existing Live Events tab below a sidebar divider. JSON view is
kept only on General and shows the full server settings document.
The Storage tab splits Storage Root, SlateDB, and Artifacts into
separate panels, with object store fields broken into one row per
field via a shared ObjectStoreRows helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-18 16:13:25 -04:00
parent e7ce0f6a86
commit a772c0feb5
No known key found for this signature in database
8 changed files with 465 additions and 233 deletions

View file

@ -51,6 +51,25 @@ export function Row({
);
}
export function SettingsPageIntro({
description,
view,
setView,
}: {
description: ReactNode;
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">{description}</p>
{view !== undefined && setView ? (
<ViewToggle view={view} setView={setView} />
) : null}
</div>
);
}
export function ViewToggle({
view,
setView,
@ -146,6 +165,59 @@ export function UrlValue({ url }: { url: string }) {
);
}
export function ObjectStoreRows({
store,
prefix,
}: {
store: ObjectStoreSettings;
prefix: string;
}) {
const prefixRow = (
<Row title="Prefix" help="Key prefix appended to every object path.">
{prefix ? <Mono>{prefix}</Mono> : <Muted>None</Muted>}
</Row>
);
if (store.type === "s3") {
return (
<>
<Row title="Type" help="Backend driver for this store.">
<Badge>s3</Badge>
</Row>
<Row title="Bucket" help="S3 bucket holding the objects.">
<Mono>{store.bucket}</Mono>
</Row>
<Row title="Region" help="AWS region the bucket lives in.">
<Mono>{store.region}</Mono>
</Row>
{store.endpoint ? (
<Row title="Endpoint" help="Custom S3-compatible endpoint URL.">
<Mono>{store.endpoint}</Mono>
</Row>
) : null}
{store.path_style ? (
<Row title="Path style" help="Use path-style addressing instead of virtual-hosted.">
<Toggle on={true} />
</Row>
) : null}
{prefixRow}
</>
);
}
return (
<>
<Row title="Type" help="Backend driver for this store.">
<Badge>local</Badge>
</Row>
<Row title="Root" help="Filesystem directory holding the objects.">
<Mono>{store.root}</Mono>
</Row>
{prefixRow}
</>
);
}
export function ObjectStoreValue({
store,
prefix,

View file

@ -32,7 +32,10 @@ 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 SettingsGeneral from "./routes/settings-general";
import * as SettingsIntegrations from "./routes/settings-integrations";
import * as SettingsSecurity from "./routes/settings-security";
import * as SettingsStorage from "./routes/settings-storage";
import * as SettingsLiveEvents from "./routes/settings-live-events";
import * as Profile from "./routes/profile";
import * as ProfileOverview from "./routes/profile-overview";
@ -130,7 +133,10 @@ export const routes: RouteObject[] = [
}),
route("settings", Settings, {
children: [
indexRoute(SettingsOverview),
indexRoute(SettingsGeneral),
route("integrations", SettingsIntegrations),
route("security", SettingsSecurity),
route("storage", SettingsStorage),
route("live-events", SettingsLiveEvents),
],
}),

View file

@ -0,0 +1,104 @@
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,
Muted,
NumberValue,
Panel,
PanelSkeleton,
Row,
SettingsPageIntro,
type SettingsView,
Toggle,
UrlValue,
} from "../components/settings-panel";
export function meta() {
return [{ title: "General settings — Fabro" }];
}
const DESCRIPTION = (
<>
Server URLs, listen address, and scheduler limits. Edit via{" "}
<code className="font-mono text-fg-2">settings.toml</code>; changes take
effect on the next server restart.
</>
);
export default function SettingsGeneral() {
const settingsQuery = useServerSettings();
const settings = settingsQuery.data;
const [view, setView] = useState<SettingsView>("settings");
if (!settings) {
return (
<div className="space-y-6">
<SettingsPageIntro description={DESCRIPTION} view={view} setView={setView} />
<PanelSkeleton />
</div>
);
}
return (
<div className="space-y-6">
<SettingsPageIntro description={DESCRIPTION} view={view} setView={setView} />
{view === "settings" ? (
<ServerPanel settings={settings} />
) : (
<CollapsibleFile
file={{
name: "server-settings.json",
contents: JSON.stringify(settings, null, 2),
lang: "json",
}}
/>
)}
</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 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>
);
}

View file

@ -0,0 +1,71 @@
import type { ServerSettings } from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import {
Panel,
PanelSkeleton,
Row,
SettingsPageIntro,
Toggle,
} from "../components/settings-panel";
export function meta() {
return [{ title: "Integrations — Fabro" }];
}
const DESCRIPTION = (
<>
External services connected to this server. Edit via{" "}
<code className="font-mono text-fg-2">settings.toml</code>; changes take
effect on the next server restart.
</>
);
export default function SettingsIntegrations() {
const settingsQuery = useServerSettings();
const settings = settingsQuery.data;
return (
<div className="space-y-6">
<SettingsPageIntro description={DESCRIPTION} />
{settings ? <IntegrationsPanel settings={settings} /> : <PanelSkeleton />}
</div>
);
}
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 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,221 +0,0 @@
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

@ -0,0 +1,79 @@
import type { ServerSettings } from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import {
Badge,
Count,
Muted,
Panel,
PanelSkeleton,
Row,
SettingsPageIntro,
UsernameList,
plural,
} from "../components/settings-panel";
export function meta() {
return [{ title: "Security — Fabro" }];
}
const DESCRIPTION = (
<>
Authentication methods and network allowlist. Edit via{" "}
<code className="font-mono text-fg-2">settings.toml</code>; changes take
effect on the next server restart.
</>
);
export default function SettingsSecurity() {
const settingsQuery = useServerSettings();
const settings = settingsQuery.data;
return (
<div className="space-y-6">
<SettingsPageIntro description={DESCRIPTION} />
{settings ? <SecurityPanel settings={settings} /> : <PanelSkeleton />}
</div>
);
}
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>
);
}

View file

@ -0,0 +1,76 @@
import type { ServerSettings } from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import {
Mono,
ObjectStoreRows,
Panel,
PanelSkeleton,
Row,
SettingsPageIntro,
} from "../components/settings-panel";
export function meta() {
return [{ title: "Storage — Fabro" }];
}
const DESCRIPTION = (
<>
Filesystem and object store locations for run state, the embedded database,
and artifacts. Edit via{" "}
<code className="font-mono text-fg-2">settings.toml</code>; changes take
effect on the next server restart.
</>
);
export default function SettingsStorage() {
const settingsQuery = useServerSettings();
const settings = settingsQuery.data;
return (
<div className="space-y-6">
<SettingsPageIntro description={DESCRIPTION} />
{settings ? (
<>
<StorageRootPanel settings={settings} />
<SlateDbPanel settings={settings} />
<ArtifactsPanel settings={settings} />
</>
) : (
<>
<PanelSkeleton />
<PanelSkeleton />
<PanelSkeleton />
</>
)}
</div>
);
}
function StorageRootPanel({ settings }: { settings: ServerSettings }) {
const { storage } = settings.server;
return (
<Panel title="Storage root">
<Row title="Path" help="Filesystem path for run state and logs.">
<Mono>{storage.root}</Mono>
</Row>
</Panel>
);
}
function SlateDbPanel({ settings }: { settings: ServerSettings }) {
const { slatedb } = settings.server;
return (
<Panel title="SlateDB">
<ObjectStoreRows store={slatedb.store} prefix={slatedb.prefix} />
</Panel>
);
}
function ArtifactsPanel({ settings }: { settings: ServerSettings }) {
const { artifacts } = settings.server;
return (
<Panel title="Artifacts">
<ObjectStoreRows store={artifacts.store} prefix={artifacts.prefix} />
</Panel>
);
}

View file

@ -1,4 +1,10 @@
import { BoltIcon, Cog6ToothIcon } from "@heroicons/react/24/outline";
import {
BoltIcon,
CircleStackIcon,
Cog6ToothIcon,
PuzzlePieceIcon,
ShieldCheckIcon,
} from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation, useMatches } from "react-router";
export function meta({}: any) {
@ -8,19 +14,43 @@ export function meta({}: any) {
export const handle = { hideHeader: true };
type NavItem = {
type?: "link";
name: string;
href: string;
icon: typeof Cog6ToothIcon;
match: (pathname: string) => boolean;
};
const navItems: NavItem[] = [
type NavDivider = { type: "divider"; key: string };
type NavEntry = NavItem | NavDivider;
const navItems: NavEntry[] = [
{
name: "Settings",
name: "General",
href: "/settings",
icon: Cog6ToothIcon,
match: (p) => p === "/settings",
},
{
name: "Integrations",
href: "/settings/integrations",
icon: PuzzlePieceIcon,
match: (p) => p.startsWith("/settings/integrations"),
},
{
name: "Security",
href: "/settings/security",
icon: ShieldCheckIcon,
match: (p) => p.startsWith("/settings/security"),
},
{
name: "Storage",
href: "/settings/storage",
icon: CircleStackIcon,
match: (p) => p.startsWith("/settings/storage"),
},
{ type: "divider", key: "after-storage" },
{
name: "Live Events",
href: "/settings/live-events",
@ -29,6 +59,10 @@ const navItems: NavItem[] = [
},
];
function isLink(entry: NavEntry): entry is NavItem {
return entry.type !== "divider";
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
@ -36,7 +70,8 @@ function classNames(...classes: Array<string | false | null | undefined>) {
export default function SettingsLayout() {
const { pathname } = useLocation();
const matches = useMatches();
const currentName = navItems.find((item) => item.match(pathname))?.name ?? "Settings";
const currentName =
navItems.filter(isLink).find((item) => item.match(pathname))?.name ?? "Settings";
const fullHeight = matches.some(
(m) => (m.handle as { fullHeight?: boolean } | undefined)?.fullHeight,
);
@ -51,12 +86,22 @@ export default function SettingsLayout() {
<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);
{navItems.map((entry) => {
if (!isLink(entry)) {
return (
<li
key={entry.key}
role="separator"
aria-orientation="vertical"
className="mx-1 self-stretch border-l border-line lg:mx-0 lg:my-2 lg:self-auto lg:border-l-0 lg:border-t"
/>
);
}
const current = entry.match(pathname);
return (
<li key={item.name}>
<li key={entry.name}>
<Link
to={item.href}
to={entry.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",
@ -65,8 +110,8 @@ export default function SettingsLayout() {
: "text-fg-3 hover:bg-overlay hover:text-fg",
)}
>
<item.icon className="size-4 shrink-0" aria-hidden="true" />
{item.name}
<entry.icon className="size-4 shrink-0" aria-hidden="true" />
{entry.name}
</Link>
</li>
);