From 0be39065ac0437213bf2843c80f28cca9eada96e Mon Sep 17 00:00:00 2001 From: Forbes Date: Sun, 15 Feb 2026 03:01:33 -0600 Subject: [PATCH] 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 --- web/src/api/types.ts | 29 + web/src/components/settings/AdminModules.tsx | 180 +++++ web/src/components/settings/ModuleCard.tsx | 655 +++++++++++++++++++ web/src/pages/SettingsPage.tsx | 4 + 4 files changed, 868 insertions(+) create mode 100644 web/src/components/settings/AdminModules.tsx create mode 100644 web/src/components/settings/ModuleCard.tsx diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b5e33b2..b28e073 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; +} + +export interface ModulesResponse { + modules: Record; + server: { version: string; read_only: boolean }; +} + +// Admin settings — config management +export type AdminSettingsResponse = Record>; + +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; diff --git a/web/src/components/settings/AdminModules.tsx b/web/src/components/settings/AdminModules.tsx new file mode 100644 index 0000000..9d84d30 --- /dev/null +++ b/web/src/components/settings/AdminModules.tsx @@ -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 | null>( + null, + ); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [restartRequired, setRestartRequired] = useState(false); + + useEffect(() => { + Promise.all([ + get("/api/modules"), + get("/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>(`/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 = { + ...prev, + [moduleId]: { ...prev[moduleId], enabled }, + }; + return updated; + }); + }; + + if (loading) { + return ( +
+

Module Configuration

+

Loading modules...

+
+ ); + } + + if (error) { + return ( +
+

Module Configuration

+

+ {error} +

+
+ ); + } + + 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 ( +
+
{title}
+ {available.map((id) => { + const meta = modules[id]; + if (!meta) return null; + return ( + + ); + })} +
+ ); + }; + + return ( +
+

Module Configuration

+ + {restartRequired && ( +
+ Restart required + Some changes require a server restart to take effect. + +
+ )} + + {renderGroup("Infrastructure", infraModules)} + {renderGroup("Features", featureModules)} +
+ ); +} + +// --- 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, +}; diff --git a/web/src/components/settings/ModuleCard.tsx b/web/src/components/settings/ModuleCard.tsx new file mode 100644 index 0000000..1dcf80c --- /dev/null +++ b/web/src/components/settings/ModuleCard.tsx @@ -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; + allModules: Record; + 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(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState( + null, + ); + const [edits, setEdits] = useState>({}); + + 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( + `/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( + `/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( + `/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 Disabled; + if (status === "unavailable") + return Error; + return Active; + }; + + return ( +
+ {/* Header */} +
hasFields && setExpanded(!expanded)} + > +
+ {!meta.required && ( + + )} + + {meta.name} + + {statusBadge()} +
+ {hasFields && ( + + ▼ + + )} +
+ + {/* Toggle error */} + {toggleError && ( +
+ {toggleError} +
+ )} + + {/* Dependencies note */} + {deps.length > 0 && expanded && ( +
+ Requires:{" "} + {deps.map((d) => allModules[d]?.name ?? d).join(", ")} +
+ )} + + {/* Body */} + {expanded && hasFields && ( +
+ {renderModuleFields(moduleId, settings, getFieldValue, setField)} + + {/* Footer */} +
+
+ {hasEdits && ( + + )} + {isTestable && ( + + )} +
+
+ {saveSuccess && ( + + Saved + + )} + {saveError && ( + + {saveError} + + )} +
+
+ + {/* Test result */} + {testResult && ( +
+ + {testResult.success ? "OK" : "Failed"} + + + {testResult.message} + + {testResult.latency_ms > 0 && ( + + {testResult.latency_ms}ms + + )} +
+ )} +
+ )} +
+ ); +} + +// --- Field renderers per module --- + +function renderModuleFields( + moduleId: string, + settings: Record, + getValue: (key: string) => unknown, + setValue: (key: string, value: unknown) => void, +) { + switch (moduleId) { + case "core": + return ( + + + + + + + ); + case "schemas": + return ( + + + + + + ); + case "database": + return ( + + + + + + + + + ); + case "storage": + return ( + + + + + + + ); + case "auth": + return renderAuthFields(settings); + case "freecad": + return ( + + setValue("uri_scheme", v)} + /> + setValue("executable", v)} + /> + + ); + case "odoo": + return ( + + setValue("url", v)} + /> + setValue("database", v)} + /> + setValue("username", v)} + /> + + ); + case "jobs": + return ( + + setValue("directory", v)} + /> + setValue("runner_timeout", Number(v))} + type="number" + /> + setValue("job_timeout_check", Number(v))} + type="number" + /> + setValue("default_priority", Number(v))} + type="number" + /> + + ); + default: + return null; + } +} + +function renderAuthFields(settings: Record) { + const local = (settings.local ?? {}) as Record; + const ldap = (settings.ldap ?? {}) as Record; + const oidc = (settings.oidc ?? {}) as Record; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +// --- Shared field components --- + +function FieldGrid({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function SubSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function ReadOnlyField({ + label, + value, +}: { + label: string; + value: unknown; +}) { + const display = + value === undefined || value === null || value === "" + ? "—" + : String(value); + return ( +
+
{label}
+
{display}
+
+ ); +} + +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 ( +
+
{label}
+ onChange(e.target.value)} + placeholder={isRedacted ? "••••••••" : undefined} + className="silo-input" + style={fieldInputStyle} + /> +
+ ); +} + +// --- 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", +}; diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 0ca6fbf..4a32a32 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -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() { )} + + {/* Admin: Module Configuration */} + {user?.role === "admin" && } ); }