From a772c0feb5fa52ed98ca93cdd5b26a5d0b1ff7d3 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 18 May 2026 16:13:25 -0400 Subject: [PATCH] feat(web): split settings into General/Integrations/Security/Storage tabs 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) --- .../app/components/settings-panel.tsx | 72 ++++++ apps/fabro-web/app/router.tsx | 10 +- .../fabro-web/app/routes/settings-general.tsx | 104 +++++++++ .../app/routes/settings-integrations.tsx | 71 ++++++ .../app/routes/settings-overview.tsx | 221 ------------------ .../app/routes/settings-security.tsx | 79 +++++++ .../fabro-web/app/routes/settings-storage.tsx | 76 ++++++ apps/fabro-web/app/routes/settings.tsx | 65 +++++- 8 files changed, 465 insertions(+), 233 deletions(-) create mode 100644 apps/fabro-web/app/routes/settings-general.tsx create mode 100644 apps/fabro-web/app/routes/settings-integrations.tsx delete mode 100644 apps/fabro-web/app/routes/settings-overview.tsx create mode 100644 apps/fabro-web/app/routes/settings-security.tsx create mode 100644 apps/fabro-web/app/routes/settings-storage.tsx diff --git a/apps/fabro-web/app/components/settings-panel.tsx b/apps/fabro-web/app/components/settings-panel.tsx index ca4861cca..1fa8bcb42 100644 --- a/apps/fabro-web/app/components/settings-panel.tsx +++ b/apps/fabro-web/app/components/settings-panel.tsx @@ -51,6 +51,25 @@ export function Row({ ); } +export function SettingsPageIntro({ + description, + view, + setView, +}: { + description: ReactNode; + view?: SettingsView; + setView?: (v: SettingsView) => void; +}) { + return ( +
+

{description}

+ {view !== undefined && setView ? ( + + ) : null} +
+ ); +} + 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 = ( + + {prefix ? {prefix} : None} + + ); + + if (store.type === "s3") { + return ( + <> + + s3 + + + {store.bucket} + + + {store.region} + + {store.endpoint ? ( + + {store.endpoint} + + ) : null} + {store.path_style ? ( + + + + ) : null} + {prefixRow} + + ); + } + + return ( + <> + + local + + + {store.root} + + {prefixRow} + + ); +} + export function ObjectStoreValue({ store, prefix, diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 602e3b234..4cb1e7f10 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -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), ], }), diff --git a/apps/fabro-web/app/routes/settings-general.tsx b/apps/fabro-web/app/routes/settings-general.tsx new file mode 100644 index 000000000..e76aec902 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-general.tsx @@ -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{" "} + settings.toml; changes take + effect on the next server restart. + +); + +export default function SettingsGeneral() { + const settingsQuery = useServerSettings(); + const settings = settingsQuery.data; + const [view, setView] = useState("settings"); + + if (!settings) { + return ( +
+ + +
+ ); + } + + return ( +
+ + {view === "settings" ? ( + + ) : ( + + )} +
+ ); +} + +function ServerPanel({ settings }: { settings: ServerSettings }) { + const { listen, web, api, scheduler } = settings.server; + return ( + + + {web.enabled ? : } + + + {api.url ? : Same origin} + + + + + + + + + ); +} + +function ListenValue({ listen }: { listen: ServerListenSettings }) { + if (listen.type === "tcp") { + return ( + + tcp + + {listen.address} + + + ); + } + return ( + + unix + + {listen.path} + + + ); +} diff --git a/apps/fabro-web/app/routes/settings-integrations.tsx b/apps/fabro-web/app/routes/settings-integrations.tsx new file mode 100644 index 000000000..cd81a2852 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-integrations.tsx @@ -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{" "} + settings.toml; changes take + effect on the next server restart. + +); + +export default function SettingsIntegrations() { + const settingsQuery = useServerSettings(); + const settings = settingsQuery.data; + + return ( +
+ + {settings ? : } +
+ ); +} + +function IntegrationsPanel({ settings }: { settings: ServerSettings }) { + const { integrations } = settings.server; + return ( + + + + + + ); +} + +function IntegrationValue({ + enabled, + detail, +}: { + enabled: boolean; + detail?: string; +}) { + if (!enabled) return ; + return ( + + + {detail ? ( + {detail} + ) : null} + + ); +} diff --git a/apps/fabro-web/app/routes/settings-overview.tsx b/apps/fabro-web/app/routes/settings-overview.tsx deleted file mode 100644 index cdab04d34..000000000 --- a/apps/fabro-web/app/routes/settings-overview.tsx +++ /dev/null @@ -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("settings"); - - if (!settings) { - return ( -
- - - - - -
- ); - } - - return ( -
- - {view === "settings" ? ( - <> - - - - - - ) : ( - - )} -
- ); -} - -function PageIntro({ - view, - setView, -}: { - view: SettingsView; - setView: (v: SettingsView) => void; -}) { - return ( -
-

- Snapshot of the server configuration. Edit via{" "} - settings.toml; changes take - effect on the next server restart. -

- -
- ); -} - -function ServerPanel({ settings }: { settings: ServerSettings }) { - const { listen, web, api, scheduler } = settings.server; - return ( - - - {web.enabled ? : } - - - {api.url ? : Same origin} - - - - - - - - - ); -} - -function DataPanel({ settings }: { settings: ServerSettings }) { - const { storage, slatedb, artifacts } = settings.server; - return ( - - - {storage.root} - - - - - - - - - ); -} - -function SecurityPanel({ settings }: { settings: ServerSettings }) { - const { auth, ip_allowlist } = settings.server; - const githubUsers = auth.github.allowed_usernames; - return ( - - - {auth.methods.length === 0 ? ( - None configured - ) : ( -
- {auth.methods.map((m) => ( - {m} - ))} -
- )} -
- - {githubUsers.length === 0 ? ( - Anyone - ) : ( - - )} - - - 0 - ? `· ${ip_allowlist.trusted_proxy_count} trusted ${plural(ip_allowlist.trusted_proxy_count, "proxy", "proxies")}` - : undefined - } - /> - -
- ); -} - -function IntegrationsPanel({ settings }: { settings: ServerSettings }) { - const { integrations } = settings.server; - return ( - - - - - - ); -} - -function ListenValue({ listen }: { listen: ServerListenSettings }) { - if (listen.type === "tcp") { - return ( - - tcp - - {listen.address} - - - ); - } - return ( - - unix - - {listen.path} - - - ); -} - -function IntegrationValue({ - enabled, - detail, -}: { - enabled: boolean; - detail?: string; -}) { - if (!enabled) return ; - return ( - - - {detail ? ( - {detail} - ) : null} - - ); -} diff --git a/apps/fabro-web/app/routes/settings-security.tsx b/apps/fabro-web/app/routes/settings-security.tsx new file mode 100644 index 000000000..fbcedd8bd --- /dev/null +++ b/apps/fabro-web/app/routes/settings-security.tsx @@ -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{" "} + settings.toml; changes take + effect on the next server restart. + +); + +export default function SettingsSecurity() { + const settingsQuery = useServerSettings(); + const settings = settingsQuery.data; + + return ( +
+ + {settings ? : } +
+ ); +} + +function SecurityPanel({ settings }: { settings: ServerSettings }) { + const { auth, ip_allowlist } = settings.server; + const githubUsers = auth.github.allowed_usernames; + return ( + + + {auth.methods.length === 0 ? ( + None configured + ) : ( +
+ {auth.methods.map((m) => ( + {m} + ))} +
+ )} +
+ + {githubUsers.length === 0 ? ( + Anyone + ) : ( + + )} + + + 0 + ? `· ${ip_allowlist.trusted_proxy_count} trusted ${plural(ip_allowlist.trusted_proxy_count, "proxy", "proxies")}` + : undefined + } + /> + +
+ ); +} diff --git a/apps/fabro-web/app/routes/settings-storage.tsx b/apps/fabro-web/app/routes/settings-storage.tsx new file mode 100644 index 000000000..78f3325b0 --- /dev/null +++ b/apps/fabro-web/app/routes/settings-storage.tsx @@ -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{" "} + settings.toml; changes take + effect on the next server restart. + +); + +export default function SettingsStorage() { + const settingsQuery = useServerSettings(); + const settings = settingsQuery.data; + + return ( +
+ + {settings ? ( + <> + + + + + ) : ( + <> + + + + + )} +
+ ); +} + +function StorageRootPanel({ settings }: { settings: ServerSettings }) { + const { storage } = settings.server; + return ( + + + {storage.root} + + + ); +} + +function SlateDbPanel({ settings }: { settings: ServerSettings }) { + const { slatedb } = settings.server; + return ( + + + + ); +} + +function ArtifactsPanel({ settings }: { settings: ServerSettings }) { + const { artifacts } = settings.server; + return ( + + + + ); +} diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx index 1173d16f4..eef4be32e 100644 --- a/apps/fabro-web/app/routes/settings.tsx +++ b/apps/fabro-web/app/routes/settings.tsx @@ -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) { return classes.filter(Boolean).join(" "); } @@ -36,7 +70,8 @@ function classNames(...classes: Array) { 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() {