feat(web): admin settings page with module cards, toggles, config forms

Add admin-only Module Configuration section to the Settings page.
Each module gets a collapsible card with enable/disable toggle,
status badge, module-specific config fields, save and test
connectivity buttons.

- AdminModules: fetches GET /api/modules + GET /api/admin/settings,
  renders Infrastructure and Features groups, restart banner
- ModuleCard: collapsible card with toggle, status badge, field
  layouts per module, save (PUT) and test (POST) actions
- TypeScript types for ModuleInfo, ModulesResponse, admin settings
  API response shapes

Ref: #100
This commit is contained in:
Forbes
2026-02-15 03:01:33 -06:00
parent 101d04ab6f
commit 0be39065ac
4 changed files with 868 additions and 0 deletions

View File

@@ -352,6 +352,35 @@ export interface UpdateSchemaValueRequest {
description: string;
}
// Admin settings — module discovery
export interface ModuleInfo {
enabled: boolean;
required: boolean;
name: string;
version?: string;
depends_on?: string[];
config?: Record<string, unknown>;
}
export interface ModulesResponse {
modules: Record<string, ModuleInfo>;
server: { version: string; read_only: boolean };
}
// Admin settings — config management
export type AdminSettingsResponse = Record<string, Record<string, unknown>>;
export interface UpdateSettingsResponse {
updated: string[];
restart_required: boolean;
}
export interface TestConnectivityResponse {
success: boolean;
message: string;
latency_ms: number;
}
// Revision comparison
export interface RevisionComparison {
from: number;

View File

@@ -0,0 +1,180 @@
import { useEffect, useState } from "react";
import { get } from "../../api/client";
import type {
ModuleInfo,
ModulesResponse,
AdminSettingsResponse,
UpdateSettingsResponse,
} from "../../api/types";
import { ModuleCard } from "./ModuleCard";
const infraModules = ["core", "schemas", "database", "storage"];
const featureModules = [
"auth",
"projects",
"audit",
"freecad",
"odoo",
"jobs",
"dag",
];
export function AdminModules() {
const [modules, setModules] = useState<Record<string, ModuleInfo> | null>(
null,
);
const [settings, setSettings] = useState<AdminSettingsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [restartRequired, setRestartRequired] = useState(false);
useEffect(() => {
Promise.all([
get<ModulesResponse>("/api/modules"),
get<AdminSettingsResponse>("/api/admin/settings"),
])
.then(([modsResp, settingsResp]) => {
setModules(modsResp.modules);
setSettings(settingsResp);
})
.catch((e) =>
setError(e instanceof Error ? e.message : "Failed to load settings"),
)
.finally(() => setLoading(false));
}, []);
const handleSaved = (moduleId: string, result: UpdateSettingsResponse) => {
if (result.restart_required) setRestartRequired(true);
// Refresh the single module's settings
get<Record<string, unknown>>(`/api/admin/settings/${moduleId}`)
.then((updated) =>
setSettings((prev) => (prev ? { ...prev, [moduleId]: updated } : prev)),
)
.catch(() => {});
};
const handleToggled = (moduleId: string, enabled: boolean) => {
setModules((prev) => {
if (!prev || !prev[moduleId]) return prev;
const updated: Record<string, ModuleInfo> = {
...prev,
[moduleId]: { ...prev[moduleId], enabled },
};
return updated;
});
};
if (loading) {
return (
<div style={sectionStyle}>
<h3 style={sectionTitleStyle}>Module Configuration</h3>
<p style={{ color: "var(--ctp-overlay0)" }}>Loading modules...</p>
</div>
);
}
if (error) {
return (
<div style={sectionStyle}>
<h3 style={sectionTitleStyle}>Module Configuration</h3>
<p style={{ color: "var(--ctp-red)", fontSize: "var(--font-body)" }}>
{error}
</p>
</div>
);
}
if (!modules || !settings) return null;
const renderGroup = (title: string, ids: string[]) => {
const available = ids.filter((id) => modules[id]);
if (available.length === 0) return null;
return (
<div style={{ marginBottom: "1.25rem" }}>
<div style={groupTitleStyle}>{title}</div>
{available.map((id) => {
const meta = modules[id];
if (!meta) return null;
return (
<ModuleCard
key={id}
moduleId={id}
meta={meta}
settings={settings[id] ?? {}}
allModules={modules}
onSaved={handleSaved}
onToggled={handleToggled}
/>
);
})}
</div>
);
};
return (
<div style={sectionStyle}>
<h3 style={sectionTitleStyle}>Module Configuration</h3>
{restartRequired && (
<div style={restartBannerStyle}>
<span style={{ fontWeight: 600 }}>Restart required</span>
<span>Some changes require a server restart to take effect.</span>
<button
onClick={() => setRestartRequired(false)}
style={dismissBtnStyle}
>
Dismiss
</button>
</div>
)}
{renderGroup("Infrastructure", infraModules)}
{renderGroup("Features", featureModules)}
</div>
);
}
// --- Styles ---
const sectionStyle: React.CSSProperties = {
marginTop: "0.5rem",
};
const sectionTitleStyle: React.CSSProperties = {
marginBottom: "1rem",
fontSize: "var(--font-title)",
};
const groupTitleStyle: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--ctp-overlay1)",
marginBottom: "0.5rem",
};
const restartBannerStyle: React.CSSProperties = {
display: "flex",
gap: "0.75rem",
alignItems: "center",
padding: "0.75rem 1rem",
marginBottom: "1rem",
borderRadius: "0.75rem",
background: "rgba(249, 226, 175, 0.1)",
border: "1px solid rgba(249, 226, 175, 0.3)",
color: "var(--ctp-yellow)",
fontSize: "var(--font-body)",
};
const dismissBtnStyle: React.CSSProperties = {
marginLeft: "auto",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
border: "none",
background: "rgba(249, 226, 175, 0.15)",
color: "var(--ctp-yellow)",
cursor: "pointer",
fontSize: "0.7rem",
fontWeight: 500,
};

View File

@@ -0,0 +1,655 @@
import { useState } from "react";
import { put, post } from "../../api/client";
import type {
ModuleInfo,
UpdateSettingsResponse,
TestConnectivityResponse,
} from "../../api/types";
interface ModuleCardProps {
moduleId: string;
meta: ModuleInfo;
settings: Record<string, unknown>;
allModules: Record<string, ModuleInfo>;
onSaved: (moduleId: string, result: UpdateSettingsResponse) => void;
onToggled: (moduleId: string, enabled: boolean) => void;
}
const testableModules = new Set(["database", "storage"]);
export function ModuleCard({
moduleId,
meta,
settings,
allModules,
onSaved,
onToggled,
}: ModuleCardProps) {
const [expanded, setExpanded] = useState(false);
const [enabled, setEnabled] = useState(meta.enabled);
const [toggling, setToggling] = useState(false);
const [toggleError, setToggleError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestConnectivityResponse | null>(
null,
);
const [edits, setEdits] = useState<Record<string, unknown>>({});
const hasEdits = Object.keys(edits).length > 0;
const isTestable = testableModules.has(moduleId);
const hasFields = !["projects", "audit", "dag"].includes(moduleId);
const deps = meta.depends_on ?? [];
const status = settings.status as string | undefined;
const handleToggle = async () => {
const next = !enabled;
setToggling(true);
setToggleError(null);
try {
const result = await put<UpdateSettingsResponse>(
`/api/admin/settings/${moduleId}`,
{ enabled: next },
);
setEnabled(next);
onToggled(moduleId, next);
onSaved(moduleId, result);
} catch (e) {
setToggleError(e instanceof Error ? e.message : "Toggle failed");
} finally {
setToggling(false);
}
};
const handleSave = async () => {
setSaving(true);
setSaveError(null);
setSaveSuccess(false);
try {
const result = await put<UpdateSettingsResponse>(
`/api/admin/settings/${moduleId}`,
edits,
);
setEdits({});
setSaveSuccess(true);
onSaved(moduleId, result);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Save failed");
} finally {
setSaving(false);
}
};
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const result = await post<TestConnectivityResponse>(
`/api/admin/settings/${moduleId}/test`,
);
setTestResult(result);
} catch (e) {
setTestResult({
success: false,
message: e instanceof Error ? e.message : "Test failed",
latency_ms: 0,
});
} finally {
setTesting(false);
}
};
const setField = (key: string, value: unknown) => {
setEdits((prev) => ({ ...prev, [key]: value }));
setSaveSuccess(false);
};
const getFieldValue = (key: string): unknown => {
if (key in edits) return edits[key];
return settings[key];
};
const statusBadge = () => {
if (!enabled && !meta.required)
return <span style={badgeStyles.disabled}>Disabled</span>;
if (status === "unavailable")
return <span style={badgeStyles.error}>Error</span>;
return <span style={badgeStyles.active}>Active</span>;
};
return (
<div style={cardStyle}>
{/* Header */}
<div
style={headerStyle}
onClick={() => hasFields && setExpanded(!expanded)}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
{!meta.required && (
<button
onClick={(e) => {
e.stopPropagation();
handleToggle();
}}
disabled={toggling}
style={{
...toggleBtnStyle,
backgroundColor: enabled
? "var(--ctp-green)"
: "var(--ctp-surface2)",
}}
title={enabled ? "Disable module" : "Enable module"}
>
<span
style={{
...toggleKnobStyle,
transform: enabled ? "translateX(14px)" : "translateX(0)",
}}
/>
</button>
)}
<span style={{ fontWeight: 600, fontSize: "var(--font-title)" }}>
{meta.name}
</span>
{statusBadge()}
</div>
{hasFields && (
<span
style={{
color: "var(--ctp-overlay1)",
fontSize: "0.75rem",
transition: "transform 0.15s ease",
transform: expanded ? "rotate(180deg)" : "rotate(0)",
cursor: "pointer",
userSelect: "none",
}}
>
</span>
)}
</div>
{/* Toggle error */}
{toggleError && (
<div style={{ ...errorStyle, margin: "0.5rem 1.5rem 0" }}>
{toggleError}
</div>
)}
{/* Dependencies note */}
{deps.length > 0 && expanded && (
<div style={depNoteStyle}>
Requires:{" "}
{deps.map((d) => allModules[d]?.name ?? d).join(", ")}
</div>
)}
{/* Body */}
{expanded && hasFields && (
<div style={bodyStyle}>
{renderModuleFields(moduleId, settings, getFieldValue, setField)}
{/* Footer */}
<div style={footerStyle}>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
style={btnPrimaryStyle}
>
{saving ? "Saving..." : "Save"}
</button>
)}
{isTestable && (
<button
onClick={handleTest}
disabled={testing}
style={btnSecondaryStyle}
>
{testing ? "Testing..." : "Test Connection"}
</button>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{saveSuccess && (
<span style={{ color: "var(--ctp-green)", fontSize: "var(--font-body)" }}>
Saved
</span>
)}
{saveError && (
<span style={{ color: "var(--ctp-red)", fontSize: "var(--font-body)" }}>
{saveError}
</span>
)}
</div>
</div>
{/* Test result */}
{testResult && (
<div
style={{
...testResultStyle,
borderColor: testResult.success
? "rgba(166, 227, 161, 0.3)"
: "rgba(243, 139, 168, 0.3)",
background: testResult.success
? "rgba(166, 227, 161, 0.08)"
: "rgba(243, 139, 168, 0.08)",
}}
>
<span
style={{
color: testResult.success
? "var(--ctp-green)"
: "var(--ctp-red)",
fontWeight: 600,
}}
>
{testResult.success ? "OK" : "Failed"}
</span>
<span style={{ color: "var(--ctp-subtext0)", fontSize: "var(--font-body)" }}>
{testResult.message}
</span>
{testResult.latency_ms > 0 && (
<span style={{ color: "var(--ctp-overlay1)", fontSize: "var(--font-body)" }}>
{testResult.latency_ms}ms
</span>
)}
</div>
)}
</div>
)}
</div>
);
}
// --- Field renderers per module ---
function renderModuleFields(
moduleId: string,
settings: Record<string, unknown>,
getValue: (key: string) => unknown,
setValue: (key: string, value: unknown) => void,
) {
switch (moduleId) {
case "core":
return (
<FieldGrid>
<ReadOnlyField label="Host" value={settings.host} />
<ReadOnlyField label="Port" value={settings.port} />
<ReadOnlyField label="Base URL" value={settings.base_url} />
<ReadOnlyField
label="Read Only"
value={settings.readonly ? "Yes" : "No"}
/>
</FieldGrid>
);
case "schemas":
return (
<FieldGrid>
<ReadOnlyField label="Directory" value={settings.directory} />
<ReadOnlyField label="Default" value={settings.default} />
<ReadOnlyField label="Schema Count" value={settings.count} />
</FieldGrid>
);
case "database":
return (
<FieldGrid>
<ReadOnlyField label="Host" value={settings.host} />
<ReadOnlyField label="Port" value={settings.port} />
<ReadOnlyField label="Database" value={settings.name} />
<ReadOnlyField label="User" value={settings.user} />
<ReadOnlyField label="SSL Mode" value={settings.sslmode} />
<ReadOnlyField label="Max Connections" value={settings.max_connections} />
</FieldGrid>
);
case "storage":
return (
<FieldGrid>
<ReadOnlyField label="Endpoint" value={settings.endpoint} />
<ReadOnlyField label="Bucket" value={settings.bucket} />
<ReadOnlyField label="SSL" value={settings.use_ssl ? "Yes" : "No"} />
<ReadOnlyField label="Region" value={settings.region} />
</FieldGrid>
);
case "auth":
return renderAuthFields(settings);
case "freecad":
return (
<FieldGrid>
<EditableField
label="URI Scheme"
value={getValue("uri_scheme")}
onChange={(v) => setValue("uri_scheme", v)}
/>
<EditableField
label="Executable"
value={getValue("executable")}
onChange={(v) => setValue("executable", v)}
/>
</FieldGrid>
);
case "odoo":
return (
<FieldGrid>
<EditableField
label="URL"
value={getValue("url")}
onChange={(v) => setValue("url", v)}
/>
<EditableField
label="Database"
value={getValue("database")}
onChange={(v) => setValue("database", v)}
/>
<EditableField
label="Username"
value={getValue("username")}
onChange={(v) => setValue("username", v)}
/>
</FieldGrid>
);
case "jobs":
return (
<FieldGrid>
<EditableField
label="Definitions Directory"
value={getValue("directory")}
onChange={(v) => setValue("directory", v)}
/>
<EditableField
label="Runner Timeout (s)"
value={getValue("runner_timeout")}
onChange={(v) => setValue("runner_timeout", Number(v))}
type="number"
/>
<EditableField
label="Timeout Check (s)"
value={getValue("job_timeout_check")}
onChange={(v) => setValue("job_timeout_check", Number(v))}
type="number"
/>
<EditableField
label="Default Priority"
value={getValue("default_priority")}
onChange={(v) => setValue("default_priority", Number(v))}
type="number"
/>
</FieldGrid>
);
default:
return null;
}
}
function renderAuthFields(settings: Record<string, unknown>) {
const local = (settings.local ?? {}) as Record<string, unknown>;
const ldap = (settings.ldap ?? {}) as Record<string, unknown>;
const oidc = (settings.oidc ?? {}) as Record<string, unknown>;
return (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<SubSection title="Local Auth">
<FieldGrid>
<ReadOnlyField label="Enabled" value={local.enabled ? "Yes" : "No"} />
<ReadOnlyField label="Default Admin" value={local.default_admin_username} />
</FieldGrid>
</SubSection>
<SubSection title="LDAP / FreeIPA">
<FieldGrid>
<ReadOnlyField label="Enabled" value={ldap.enabled ? "Yes" : "No"} />
<ReadOnlyField label="URL" value={ldap.url} />
<ReadOnlyField label="Base DN" value={ldap.base_dn} />
<ReadOnlyField label="Bind DN" value={ldap.bind_dn} />
</FieldGrid>
</SubSection>
<SubSection title="OIDC / Keycloak">
<FieldGrid>
<ReadOnlyField label="Enabled" value={oidc.enabled ? "Yes" : "No"} />
<ReadOnlyField label="Issuer URL" value={oidc.issuer_url} />
<ReadOnlyField label="Client ID" value={oidc.client_id} />
<ReadOnlyField label="Redirect URL" value={oidc.redirect_url} />
</FieldGrid>
</SubSection>
</div>
);
}
// --- Shared field components ---
function FieldGrid({ children }: { children: React.ReactNode }) {
return <div style={fieldGridStyle}>{children}</div>;
}
function SubSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<div style={subSectionTitleStyle}>{title}</div>
{children}
</div>
);
}
function ReadOnlyField({
label,
value,
}: {
label: string;
value: unknown;
}) {
const display =
value === undefined || value === null || value === ""
? "—"
: String(value);
return (
<div>
<div style={fieldLabelStyle}>{label}</div>
<div style={fieldValueStyle}>{display}</div>
</div>
);
}
function EditableField({
label,
value,
onChange,
type = "text",
}: {
label: string;
value: unknown;
onChange: (v: string) => void;
type?: string;
}) {
const strVal = value === undefined || value === null ? "" : String(value);
const isRedacted = strVal === "****";
return (
<div>
<div style={fieldLabelStyle}>{label}</div>
<input
type={type}
value={isRedacted ? "" : strVal}
onChange={(e) => onChange(e.target.value)}
placeholder={isRedacted ? "••••••••" : undefined}
className="silo-input"
style={fieldInputStyle}
/>
</div>
);
}
// --- Styles ---
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.75rem",
marginBottom: "0.75rem",
overflow: "hidden",
};
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1rem 1.5rem",
cursor: "pointer",
userSelect: "none",
};
const bodyStyle: React.CSSProperties = {
padding: "0 1.5rem 1.25rem",
};
const footerStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "1rem",
paddingTop: "0.75rem",
borderTop: "1px solid var(--ctp-surface1)",
};
const toggleBtnStyle: React.CSSProperties = {
position: "relative",
width: 34,
height: 20,
borderRadius: 10,
border: "none",
cursor: "pointer",
padding: 0,
flexShrink: 0,
transition: "background-color 0.15s ease",
};
const toggleKnobStyle: React.CSSProperties = {
position: "absolute",
top: 3,
left: 3,
width: 14,
height: 14,
borderRadius: "50%",
backgroundColor: "var(--ctp-crust)",
transition: "transform 0.15s ease",
};
const badgeBase: React.CSSProperties = {
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.65rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
};
const badgeStyles = {
active: {
...badgeBase,
background: "rgba(166, 227, 161, 0.2)",
color: "var(--ctp-green)",
} as React.CSSProperties,
disabled: {
...badgeBase,
background: "rgba(147, 153, 178, 0.15)",
color: "var(--ctp-overlay1)",
} as React.CSSProperties,
error: {
...badgeBase,
background: "rgba(243, 139, 168, 0.2)",
color: "var(--ctp-red)",
} as React.CSSProperties,
};
const errorStyle: React.CSSProperties = {
color: "var(--ctp-red)",
fontSize: "var(--font-body)",
};
const depNoteStyle: React.CSSProperties = {
padding: "0 1.5rem",
color: "var(--ctp-overlay1)",
fontSize: "var(--font-body)",
fontStyle: "italic",
};
const fieldGridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.75rem 1.5rem",
};
const subSectionTitleStyle: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--ctp-overlay1)",
marginBottom: "0.5rem",
paddingBottom: "0.25rem",
borderBottom: "1px solid var(--ctp-surface1)",
};
const fieldLabelStyle: React.CSSProperties = {
color: "var(--ctp-subtext0)",
fontSize: "var(--font-body)",
fontWeight: 500,
marginBottom: "0.2rem",
};
const fieldValueStyle: React.CSSProperties = {
fontSize: "var(--font-body)",
color: "var(--ctp-text)",
fontFamily: "'JetBrains Mono', monospace",
};
const fieldInputStyle: React.CSSProperties = {
width: "100%",
padding: "0.4rem 0.6rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
color: "var(--ctp-text)",
fontSize: "var(--font-body)",
boxSizing: "border-box",
};
const btnPrimaryStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
fontWeight: 500,
fontSize: "0.75rem",
cursor: "pointer",
};
const btnSecondaryStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
borderRadius: "0.25rem",
border: "1px solid var(--ctp-surface2)",
backgroundColor: "transparent",
color: "var(--ctp-subtext1)",
fontWeight: 500,
fontSize: "0.75rem",
cursor: "pointer",
};
const testResultStyle: React.CSSProperties = {
display: "flex",
gap: "0.75rem",
alignItems: "center",
marginTop: "0.75rem",
padding: "0.5rem 0.75rem",
borderRadius: "0.5rem",
border: "1px solid",
};

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react";
import { get, post, del } from "../api/client";
import { useAuth } from "../hooks/useAuth";
import type { ApiToken, ApiTokenCreated } from "../api/types";
import { AdminModules } from "../components/settings/AdminModules";
export function SettingsPage() {
const { user } = useAuth();
@@ -311,6 +312,9 @@ export function SettingsPage() {
</div>
)}
</div>
{/* Admin: Module Configuration */}
{user?.role === "admin" && <AdminModules />}
</div>
);
}