mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
this is a big slop that might backfire lol... new config system and UI!
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// Administration Overview
|
||||
// Purpose: Summarizes configuration state, revision history, audit history, and links to existing health/report surfaces.
|
||||
// Scope: Presents persisted administration metadata without duplicating operational service implementations.
|
||||
import { Link } from 'react-router-dom';
|
||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||
import { restoreConfigurationRevision } from '../api.js';
|
||||
|
||||
function formatDate(value) {
|
||||
return Number.isFinite(Number(value)) ? new Date(Number(value)).toLocaleString() : 'unknown';
|
||||
}
|
||||
|
||||
export default function AdminOverview({ snapshot, socket, runSensitive, onSnapshot }) {
|
||||
const config = snapshot.configuration;
|
||||
|
||||
async function restore(revision) {
|
||||
if (!window.confirm(`Restore configuration revision ${revision}? This creates a new active revision and requires a restart.`)) return;
|
||||
try {
|
||||
const response = await runSensitive(() => restoreConfigurationRevision(socket, {
|
||||
revision,
|
||||
expectedRevision: config.revision,
|
||||
}));
|
||||
onSnapshot(response.snapshot);
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<CardFrame title="Administration overview" meta={`revision ${config.revision}`} bodyClassName="grid gap-0.5 p-0.5 md:grid-cols-3">
|
||||
<div className="surface p-1"><p className="text-xs text-slate-400">Active revision</p><p className="text-xl font-semibold">{config.revision}</p></div>
|
||||
<div className="surface p-1"><p className="text-xs text-slate-400">Administrators</p><p className="text-xl font-semibold">{snapshot.administrators.length}</p></div>
|
||||
<div className="surface p-1"><p className="text-xs text-slate-400">Last configuration save</p><p className="text-sm font-semibold">{formatDate(config.createdAt)}</p></div>
|
||||
</CardFrame>
|
||||
<CardFrame title="Existing administration surfaces" bodyClassName="flex flex-wrap gap-0.5 p-0.5 text-sm">
|
||||
<Link className="button-dark" to="/reports">Open fleet reports</Link>
|
||||
<Link className="button-dark" to="/">Open driver application</Link>
|
||||
</CardFrame>
|
||||
<CardFrame title="Configuration revisions" meta={snapshot.revisions.length} bodyClassName="max-h-64 overflow-y-auto p-0.5 text-xs">
|
||||
{snapshot.revisions.map((revision) => (
|
||||
<div key={revision.revision} className="surface mb-0.5 grid items-center gap-0.5 p-0.5 md:grid-cols-[5rem_1fr_1fr_1fr_auto]">
|
||||
<span>#{revision.revision}</span><span>{formatDate(revision.createdAt)}</span><span>{revision.actor}</span><span>{revision.source}</span>
|
||||
<button type="button" className="button-dark text-xs" disabled={revision.revision === config.revision} onClick={() => restore(revision.revision)}>Restore</button>
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
<CardFrame title="Persistent audit history" meta={snapshot.auditEvents.length} bodyClassName="max-h-96 overflow-y-auto p-0.5 text-xs">
|
||||
{snapshot.auditEvents.map((event) => (
|
||||
<div key={event.id} className="surface mb-0.5 grid gap-0.5 p-0.5 md:grid-cols-[10rem_10rem_1fr]">
|
||||
<span>{formatDate(event.createdAt)}</span><span>{event.actor}</span><span>{event.action}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Administrator Accounts
|
||||
// Purpose: Provides lockdown administrators with explicit account creation, editing, password replacement, and deletion controls.
|
||||
// Scope: Never receives or displays password hashes; final-lockdown safety is enforced by the server.
|
||||
import { useEffect, useState } from 'react';
|
||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||
import { createAdministrator, deleteAdministrator, updateAdministrator } from '../api.js';
|
||||
|
||||
function AdministratorRow({ administrator, socket, runSensitive, onSnapshot }) {
|
||||
const [draft, setDraft] = useState({
|
||||
username: administrator.username,
|
||||
discordId: administrator.discordId,
|
||||
role: administrator.role,
|
||||
password: '',
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setDraft({ username: administrator.username, discordId: administrator.discordId, role: administrator.role, password: '' });
|
||||
}, [administrator]);
|
||||
|
||||
async function perform(work) {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await runSensitive(work);
|
||||
onSnapshot(response.snapshot);
|
||||
} catch (actionError) {
|
||||
setError(actionError.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="surface space-y-0.5 p-0.5">
|
||||
<div className="grid gap-0.5 md:grid-cols-4">
|
||||
<input className="field-input" value={draft.username} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
|
||||
<input className="field-input" placeholder="Discord id" value={draft.discordId} onChange={(event) => setDraft({ ...draft, discordId: event.target.value })} />
|
||||
<select className="field-input" value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value })}>
|
||||
<option value="admin">Administrator</option>
|
||||
<option value="lockdown">Lockdown administrator</option>
|
||||
</select>
|
||||
<input className="field-input" type="password" autoComplete="new-password" placeholder="New password (optional)" value={draft.password} onChange={(event) => setDraft({ ...draft, password: event.target.value })} />
|
||||
</div>
|
||||
{error ? <p className="text-xs text-red-300">{error}</p> : null}
|
||||
<div className="flex justify-end gap-0.5">
|
||||
<button type="button" className="button-danger text-xs" disabled={busy} onClick={() => perform(() => deleteAdministrator(socket, administrator.id))}>Delete</button>
|
||||
<button type="button" className="button-dark text-xs" disabled={busy} onClick={() => perform(() => updateAdministrator(socket, { id: administrator.id, ...draft }))}>Save account</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdministratorAccounts({ administrators, socket, runSensitive, onSnapshot }) {
|
||||
const [draft, setDraft] = useState({ username: '', discordId: '', role: 'admin', password: '' });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function create(event) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await runSensitive(() => createAdministrator(socket, draft));
|
||||
onSnapshot(response.snapshot);
|
||||
setDraft({ username: '', discordId: '', role: 'admin', password: '' });
|
||||
} catch (actionError) {
|
||||
setError(actionError.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CardFrame title="Administrator accounts" meta={administrators.length} bodyClassName="space-y-0.5 p-0.5 text-sm">
|
||||
<form className="surface grid gap-0.5 p-0.5 md:grid-cols-4" onSubmit={create}>
|
||||
<input className="field-input" placeholder="Username" value={draft.username} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
|
||||
<input className="field-input" placeholder="Discord id (optional)" value={draft.discordId} onChange={(event) => setDraft({ ...draft, discordId: event.target.value })} />
|
||||
<select className="field-input" value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value })}>
|
||||
<option value="admin">Administrator</option>
|
||||
<option value="lockdown">Lockdown administrator</option>
|
||||
</select>
|
||||
<input className="field-input" type="password" autoComplete="new-password" placeholder="Password" value={draft.password} onChange={(event) => setDraft({ ...draft, password: event.target.value })} />
|
||||
{error ? <p className="text-xs text-red-300 md:col-span-3">{error}</p> : <span className="md:col-span-3" />}
|
||||
<button className="button-dark" type="submit" disabled={busy || !draft.username || !draft.password}>{busy ? 'Creating…' : 'Create account'}</button>
|
||||
</form>
|
||||
{(administrators || []).map((administrator) => (
|
||||
<AdministratorRow key={administrator.id} administrator={administrator} socket={socket} runSensitive={runSensitive} onSnapshot={onSnapshot} />
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Complete Configuration Editor
|
||||
// Purpose: Connects the one hierarchical configuration form to revision, secret, validation, and save behavior.
|
||||
// Scope: Edits and saves one complete configuration document as one immutable revision.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { updateConfiguration } from '../api.js';
|
||||
import SchemaConfigurationForm from './SchemaConfigurationForm.jsx';
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export default function ConfigurationEditor({ snapshot, socket, runSensitive, onSnapshot, onReload }) {
|
||||
const serverValue = snapshot?.configuration?.config;
|
||||
const schema = snapshot?.configuration?.schema;
|
||||
const revision = snapshot?.configuration?.revision;
|
||||
const [draft, setDraft] = useState(() => clone(serverValue));
|
||||
const [secretOperations, setSecretOperations] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [validationErrors, setValidationErrors] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(clone(serverValue));
|
||||
setSecretOperations({});
|
||||
setError('');
|
||||
setValidationErrors([]);
|
||||
}, [revision, serverValue]);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(serverValue) || Object.keys(secretOperations).length > 0,
|
||||
[draft, secretOperations, serverValue],
|
||||
);
|
||||
|
||||
if (!serverValue || !schema) {
|
||||
return <p className="surface p-1 text-sm text-red-200">The configuration document is unavailable.</p>;
|
||||
}
|
||||
|
||||
const setSecretOperation = (path, operation) => setSecretOperations((current) => {
|
||||
const next = { ...current };
|
||||
if (operation) next[path] = operation;
|
||||
else delete next[path];
|
||||
return next;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setValidationErrors([]);
|
||||
try {
|
||||
const response = await runSensitive(() => updateConfiguration(socket, {
|
||||
value: draft,
|
||||
expectedRevision: revision,
|
||||
secretOperations,
|
||||
}));
|
||||
onSnapshot(response.snapshot);
|
||||
} catch (saveError) {
|
||||
setError(saveError.message);
|
||||
setValidationErrors(saveError.validationErrors || []);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-0.5 border border-neutral-500/60 bg-neutral-900/95 p-0.5 backdrop-blur">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-100">Configuration</p>
|
||||
<p className="text-[0.7rem] text-slate-400">Editing the complete revision {revision}. Saved changes apply after an application restart.</p>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<button type="button" className="button-dark" disabled={saving} onClick={onReload}>Reload</button>
|
||||
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={() => {
|
||||
setDraft(clone(serverValue));
|
||||
setSecretOperations({});
|
||||
}}>Reset</button>
|
||||
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={save}>{saving ? 'Saving…' : 'Save configuration'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">{error}</p> : null}
|
||||
{validationErrors.length ? (
|
||||
<div className="border border-red-500/60 bg-red-950/40 p-1 text-xs text-red-100">
|
||||
<p className="font-semibold">Configuration could not be saved</p>
|
||||
<ul className="mt-0.5 list-disc space-y-0.25 pl-4">
|
||||
{validationErrors.map((validationError, index) => (
|
||||
<li key={`${validationError.path}-${index}`}>{validationError.path}: {validationError.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<SchemaConfigurationForm
|
||||
schema={schema}
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
configuredSecrets={snapshot.configuration.configuredSecrets}
|
||||
secretOperations={secretOperations}
|
||||
setSecretOperation={setSecretOperation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Password Confirmation Dialog
|
||||
// Purpose: Collects recent password proof for sensitive administrative mutations without storing it in page state longer than necessary.
|
||||
// Scope: Handles confirmation presentation only; retry ownership remains in AdminApp.
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function PasswordConfirmationDialog({ open, busy, error, onCancel, onConfirm }) {
|
||||
const [password, setPassword] = useState('');
|
||||
if (!open) return null;
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
await onConfirm(password);
|
||||
setPassword('');
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
// Clear the credential before closing because this component remains
|
||||
// mounted beneath the admin shell and would otherwise retain it in memory.
|
||||
setPassword('');
|
||||
onCancel();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/75 p-2">
|
||||
<form className="w-full max-w-sm border border-neutral-500/70 bg-neutral-900 p-1 text-slate-100 shadow-2xl" onSubmit={submit}>
|
||||
<h2 className="text-base font-semibold">Confirm password</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-400">This sensitive action requires recent confirmation of your administrator password.</p>
|
||||
<input
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
className="field-input mt-1 w-full"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
{error ? <p className="mt-0.5 text-xs text-red-300">{error}</p> : null}
|
||||
<div className="mt-1 flex justify-end gap-0.5">
|
||||
<button type="button" className="button-dark" disabled={busy} onClick={cancel}>Cancel</button>
|
||||
<button type="submit" className="button-dark" disabled={busy || !password}>{busy ? 'Confirming…' : 'Confirm'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Schema-Generated Configuration Form
|
||||
// Purpose: Renders the server-provided JSON Schema as one hierarchical form without feature-specific React components.
|
||||
// Scope: Adds only the generic secret interaction that ordinary JSON Schema form controls cannot safely infer.
|
||||
import { useMemo } from 'react';
|
||||
import Form from '@rjsf/core';
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
|
||||
function buildUiSchema(schema, path = '') {
|
||||
/*
|
||||
`writeOnly` is standard JSON Schema metadata and is the sole reason a field
|
||||
needs a special widget. The walk records its dotted path for the existing
|
||||
server secret-operation protocol; it contains no service or field names.
|
||||
*/
|
||||
if (!schema || typeof schema !== 'object') return {};
|
||||
if (schema.writeOnly === true) {
|
||||
return {
|
||||
'ui:widget': 'SecretWidget',
|
||||
'ui:options': { secretPath: path },
|
||||
};
|
||||
}
|
||||
|
||||
if (schema.type !== 'object' || !schema.properties) return {};
|
||||
return Object.fromEntries(Object.entries(schema.properties).map(([key, childSchema]) => [
|
||||
key,
|
||||
buildUiSchema(childSchema, path ? `${path}.${key}` : key),
|
||||
]));
|
||||
}
|
||||
|
||||
function SecretWidget({ id, disabled, readonly, options, registry }) {
|
||||
const secretPath = options.secretPath;
|
||||
const context = registry.formContext || {};
|
||||
const operation = context.secretOperations?.[secretPath];
|
||||
const configured = Boolean(context.configuredSecrets?.[secretPath]);
|
||||
const replacing = operation?.action === 'replace';
|
||||
const clearing = operation?.action === 'clear';
|
||||
const unavailable = disabled || readonly;
|
||||
|
||||
function setOperation(nextOperation) {
|
||||
if (!unavailable) context.setSecretOperation?.(secretPath, nextOperation);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="surface space-y-0.5 p-0.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
||||
<span className="text-[0.7rem] text-slate-500">
|
||||
{clearing ? 'Will be cleared when saved' : replacing ? 'Replacement pending' : configured ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button type="button" className="button-dark text-xs" disabled={unavailable} onClick={() => setOperation(replacing ? null : { action: 'replace', value: '' })}>
|
||||
{replacing ? 'Cancel replace' : 'Replace'}
|
||||
</button>
|
||||
<button type="button" className={clearing ? 'button-dark text-xs' : 'button-danger text-xs'} disabled={unavailable} onClick={() => setOperation(clearing ? null : { action: 'clear' })}>
|
||||
{clearing ? 'Undo clear' : 'Clear'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{replacing ? (
|
||||
<input
|
||||
id={id}
|
||||
className="field-input w-full"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={operation.value}
|
||||
placeholder="Enter replacement value"
|
||||
disabled={unavailable}
|
||||
onChange={(event) => setOperation({ action: 'replace', value: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchemaConfigurationForm({ schema, value, onChange, configuredSecrets, secretOperations, setSecretOperation }) {
|
||||
const uiSchema = useMemo(() => buildUiSchema(schema), [schema]);
|
||||
const widgets = useMemo(() => ({ SecretWidget }), []);
|
||||
const formContext = useMemo(() => ({
|
||||
configuredSecrets,
|
||||
secretOperations,
|
||||
setSecretOperation,
|
||||
}), [configuredSecrets, secretOperations, setSecretOperation]);
|
||||
|
||||
return (
|
||||
<div className="configuration-schema-form">
|
||||
<Form
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={value}
|
||||
validator={validator}
|
||||
widgets={widgets}
|
||||
formContext={formContext}
|
||||
noHtml5Validate
|
||||
showErrorList={false}
|
||||
onChange={({ formData }) => onChange(formData)}
|
||||
>
|
||||
{/* Saving is owned by the sticky revision-aware toolbar above the form. */}
|
||||
<></>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user