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
181 lines
4.7 KiB
TypeScript
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,
|
|
};
|