mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
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
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:
parent
e7ce0f6a86
commit
a772c0feb5
8 changed files with 465 additions and 233 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
104
apps/fabro-web/app/routes/settings-general.tsx
Normal file
104
apps/fabro-web/app/routes/settings-general.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/fabro-web/app/routes/settings-integrations.tsx
Normal file
71
apps/fabro-web/app/routes/settings-integrations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
79
apps/fabro-web/app/routes/settings-security.tsx
Normal file
79
apps/fabro-web/app/routes/settings-security.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/fabro-web/app/routes/settings-storage.tsx
Normal file
76
apps/fabro-web/app/routes/settings-storage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue