feat(web): regroup settings nav under General and Administration

Group sidebar items under General and Administration section labels;
default Settings landing page to Models; rename General page to Server
(now at /settings/server); rename Resources to Monitoring (now at
/settings/monitoring) with ChartBarSquare icon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-25 14:56:01 -04:00
parent 67d8c5906e
commit a508ee3d2e
No known key found for this signature in database
6 changed files with 59 additions and 44 deletions

View file

@ -33,9 +33,9 @@ describe("browser router", () => {
expect(paths).not.toContain("/setup/complete");
});
test("exposes the resources settings page", () => {
test("exposes the monitoring settings page", () => {
const paths = collectPaths(routes);
expect(paths).toContain("/settings/resources");
expect(paths).toContain("/settings/monitoring");
});
});

View file

@ -40,7 +40,7 @@ import * as SettingsModels from "./routes/settings-models";
import * as SettingsSandboxes from "./routes/settings-sandboxes";
import * as SettingsSecrets from "./routes/settings-secrets";
import * as SettingsSecretsNew from "./routes/settings-secrets-new";
import * as SettingsResources from "./routes/settings-resources";
import * as SettingsMonitoring from "./routes/settings-monitoring";
import * as SettingsSecurity from "./routes/settings-security";
import * as SettingsStorage from "./routes/settings-storage";
import * as SettingsLiveEvents from "./routes/settings-live-events";
@ -142,15 +142,16 @@ export const routes: RouteObject[] = [
}),
route("settings", Settings, {
children: [
indexRoute(SettingsGeneral),
route("integrations", SettingsIntegrations),
indexRoute(SettingsModels),
route("models", SettingsModels),
route("integrations", SettingsIntegrations),
route("sandboxes", SettingsSandboxes),
route("security", SettingsSecurity),
route("secrets", SettingsSecrets),
route("secrets/new", SettingsSecretsNew),
route("security", SettingsSecurity),
route("server", SettingsGeneral),
route("storage", SettingsStorage),
route("resources", SettingsResources),
route("monitoring", SettingsMonitoring),
route("live-events", SettingsLiveEvents),
],
}),

View file

@ -21,7 +21,7 @@ import {
} from "../components/settings-panel";
export function meta() {
return [{ title: "General settings — Fabro" }];
return [{ title: "Server settings — Fabro" }];
}
const DESCRIPTION = (

View file

@ -21,14 +21,14 @@ mock.module("../lib/queries", () => ({
useSystemResources: () => ({ data: systemResources }),
}));
const { default: SettingsResources } = await import("./settings-resources");
const { default: SettingsMonitoring } = await import("./settings-monitoring");
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
function renderSettingsResources() {
function renderSettingsMonitoring() {
let renderer: TestRenderer.ReactTestRenderer | undefined;
act(() => {
renderer = TestRenderer.create(<SettingsResources />);
renderer = TestRenderer.create(<SettingsMonitoring />);
});
mountedRenderers.push(renderer!);
return renderer!;
@ -103,7 +103,7 @@ function sampleServerSettings(maxConcurrentRuns = 8): ServerSettings {
} as unknown as ServerSettings;
}
describe("SettingsResources route", () => {
describe("SettingsMonitoring route", () => {
beforeEach(() => {
teardownReactTestEnv = setupReactTestEnv();
systemInfo = { runs: { active: 3, total: 12 } };
@ -126,7 +126,7 @@ describe("SettingsResources route", () => {
test("renders loaded resource data", () => {
systemResources = sampleResources();
const renderer = renderSettingsResources();
const renderer = renderSettingsMonitoring();
const text = textContent(renderer.toJSON());
expect(text).toContain("18%");
@ -148,7 +148,7 @@ describe("SettingsResources route", () => {
},
});
const renderer = renderSettingsResources();
const renderer = renderSettingsMonitoring();
expect(textContent(renderer.toJSON())).toContain("Collecting sample");
});
@ -179,7 +179,7 @@ describe("SettingsResources route", () => {
},
});
const renderer = renderSettingsResources();
const renderer = renderSettingsMonitoring();
const text = textContent(renderer.toJSON());
expect(text).toContain("Unsupported");
@ -192,7 +192,7 @@ describe("SettingsResources route", () => {
notes: ["Memory is scoped to the current container."],
});
const renderer = renderSettingsResources();
const renderer = renderSettingsMonitoring();
expect(textContent(renderer.toJSON())).toContain(
"Memory is scoped to the current container.",

View file

@ -17,13 +17,13 @@ import {
} from "../components/settings-panel";
export function meta() {
return [{ title: "Resources — Fabro" }];
return [{ title: "Monitoring — Fabro" }];
}
const DESCRIPTION =
"Server-visible run concurrency, CPU, memory, and storage filesystem usage for this Fabro process.";
export default function SettingsResources() {
export default function SettingsMonitoring() {
const resourcesQuery = useSystemResources();
const resources = resourcesQuery.data;

View file

@ -1,12 +1,12 @@
import {
BoltIcon,
ChartBarSquareIcon,
CircleStackIcon,
Cog6ToothIcon,
CpuChipIcon,
CubeTransparentIcon,
KeyIcon,
PuzzlePieceIcon,
ServerStackIcon,
ShieldCheckIcon,
} from "@heroicons/react/24/outline";
import { Link, Outlet, useLocation, useMatches } from "react-router";
@ -25,28 +25,19 @@ type NavItem = {
match: (pathname: string) => boolean;
};
type NavSection = { type: "section"; key: string; label: string };
type NavDivider = { type: "divider"; key: string };
type NavEntry = NavItem | NavDivider;
type NavEntry = NavItem | NavSection | NavDivider;
const navItems: NavEntry[] = [
{
name: "General",
href: "/settings",
icon: Cog6ToothIcon,
match: (p) => p === "/settings",
},
{ type: "section", key: "general", label: "General" },
{
name: "Models",
href: "/settings/models",
icon: CpuChipIcon,
match: (p) => p.startsWith("/settings/models"),
},
{
name: "Sandboxes",
href: "/settings/sandboxes",
icon: CubeTransparentIcon,
match: (p) => p.startsWith("/settings/sandboxes"),
match: (p) => p === "/settings" || p.startsWith("/settings/models"),
},
{
name: "Integrations",
@ -55,10 +46,10 @@ const navItems: NavEntry[] = [
match: (p) => p.startsWith("/settings/integrations"),
},
{
name: "Secrets",
href: "/settings/secrets",
icon: KeyIcon,
match: (p) => p.startsWith("/settings/secrets"),
name: "Sandboxes",
href: "/settings/sandboxes",
icon: CubeTransparentIcon,
match: (p) => p.startsWith("/settings/sandboxes"),
},
{
name: "Security",
@ -66,6 +57,19 @@ const navItems: NavEntry[] = [
icon: ShieldCheckIcon,
match: (p) => p.startsWith("/settings/security"),
},
{
name: "Secrets",
href: "/settings/secrets",
icon: KeyIcon,
match: (p) => p.startsWith("/settings/secrets"),
},
{ type: "section", key: "administration", label: "Administration" },
{
name: "Server",
href: "/settings/server",
icon: Cog6ToothIcon,
match: (p) => p.startsWith("/settings/server"),
},
{
name: "Storage",
href: "/settings/storage",
@ -73,12 +77,12 @@ const navItems: NavEntry[] = [
match: (p) => p.startsWith("/settings/storage"),
},
{
name: "Resources",
href: "/settings/resources",
icon: ServerStackIcon,
match: (p) => p.startsWith("/settings/resources"),
name: "Monitoring",
href: "/settings/monitoring",
icon: ChartBarSquareIcon,
match: (p) => p.startsWith("/settings/monitoring"),
},
{ type: "divider", key: "after-storage" },
{ type: "divider", key: "after-administration" },
{
name: "Live Events",
href: "/settings/live-events",
@ -88,7 +92,7 @@ const navItems: NavEntry[] = [
];
function isLink(entry: NavEntry): entry is NavItem {
return entry.type !== "divider";
return entry.type !== "divider" && entry.type !== "section";
}
function classNames(...classes: Array<string | false | null | undefined>) {
@ -115,7 +119,7 @@ export default function SettingsLayout() {
<nav className="sticky top-6">
<ul role="list" className="flex gap-1 overflow-x-auto lg:flex-col lg:gap-0.5">
{navItems.map((entry) => {
if (!isLink(entry)) {
if (entry.type === "divider") {
return (
<li
key={entry.key}
@ -125,6 +129,16 @@ export default function SettingsLayout() {
/>
);
}
if (entry.type === "section") {
return (
<li
key={entry.key}
className="hidden lg:block lg:px-2.5 lg:pt-4 lg:pb-1 lg:text-xs lg:font-medium lg:uppercase lg:tracking-wider lg:text-fg-muted first:lg:pt-0"
>
{entry.label}
</li>
);
}
const current = entry.match(pathname);
return (
<li key={entry.name}>