mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
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:
parent
7b832c74a8
commit
a74ae2e7f8
4 changed files with 299 additions and 217 deletions
|
|
@ -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),
|
||||
],
|
||||
},
|
||||
|
|
|
|||
3
apps/fabro-web/app/routes/settings-live-events.tsx
Normal file
3
apps/fabro-web/app/routes/settings-live-events.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function SettingsLiveEvents() {
|
||||
return <div />;
|
||||
}
|
||||
221
apps/fabro-web/app/routes/settings-overview.tsx
Normal file
221
apps/fabro-web/app/routes/settings-overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue