Files
silo/web/src/components/settings/AdminModules.tsx
Forbes 0be39065ac 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
2026-02-15 03:01:33 -06:00

181 lines
4.7 KiB
TypeScript

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,
};