mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
config uislopping
This commit is contained in:
@@ -7,6 +7,7 @@ import AuthPanel from '../components/AuthPanel/index.jsx';
|
||||
import AdminPanelContent from '../components/AdminPanel/AdminPanelContent.jsx';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanels } from '../components/Tabs/index.jsx';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import IdentityDatabasePanel from '../database/IdentityDatabasePanel.jsx';
|
||||
@@ -26,18 +27,6 @@ const TOP_LEVEL_SECTIONS = [
|
||||
{ key: 'configuration', label: 'Configuration', lockdownOnly: true },
|
||||
];
|
||||
|
||||
function NavigationButton({ active, children, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full px-1 py-0.5 text-left text-xs ${active ? 'bg-sky-800 text-white' : 'surface hover:bg-neutral-700'}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminApp() {
|
||||
useUserIdentitySync({ identitySurface: 'passive' });
|
||||
const socket = useSocket();
|
||||
@@ -125,11 +114,12 @@ export default function AdminApp() {
|
||||
}
|
||||
|
||||
const navigationOptions = TOP_LEVEL_SECTIONS.filter((entry) => !entry.lockdownOnly || isLockdown);
|
||||
const activeSection = navigationOptions.some((entry) => entry.key === selected) ? selected : 'overview';
|
||||
|
||||
let content;
|
||||
if (!isAdmin) {
|
||||
content = <div className="mx-auto w-full max-w-md"><AuthPanel /></div>;
|
||||
} else if (selected === 'fleet') {
|
||||
} else if (activeSection === 'fleet') {
|
||||
content = <AdminPanelContent />;
|
||||
} else if (!isLockdown) {
|
||||
content = (
|
||||
@@ -139,14 +129,14 @@ export default function AdminApp() {
|
||||
);
|
||||
} else if (!snapshot) {
|
||||
content = <CardFrame title="Loading administration" bodyClassName="p-1 text-sm text-slate-300"><p>{loadingError || 'Loading configuration and audit state…'}</p></CardFrame>;
|
||||
} else if (selected === 'users') {
|
||||
} else if (activeSection === 'users') {
|
||||
content = (
|
||||
<div className="space-y-0.5">
|
||||
<AdministratorAccounts administrators={snapshot.administrators} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} />
|
||||
<IdentityDatabasePanel />
|
||||
</div>
|
||||
);
|
||||
} else if (selected === 'configuration') {
|
||||
} else if (activeSection === 'configuration') {
|
||||
content = <ConfigurationEditor snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} onReload={loadSnapshot} />;
|
||||
} else {
|
||||
content = <AdminOverview snapshot={snapshot} socket={socket} runSensitive={runSensitive} onSnapshot={setSnapshot} />;
|
||||
@@ -161,20 +151,18 @@ export default function AdminApp() {
|
||||
<p>{snapshot ? `Active configuration revision ${snapshot.configuration.revision}.${snapshot.restartRequired ? ' An application restart is required to apply saved changes.' : ' The running application has loaded this revision.'}` : 'Central server administration and configuration.'}</p>
|
||||
</CardFrame>
|
||||
{isAdmin ? (
|
||||
<select className="field-input my-0.5 w-full lg:hidden" value={navigationOptions.some((entry) => entry.key === selected) ? selected : 'overview'} onChange={(event) => selectSection(event.target.value)}>
|
||||
{navigationOptions.map((entry) => <option key={entry.key} value={entry.key}>{entry.label}</option>)}
|
||||
</select>
|
||||
) : null}
|
||||
<div className="mt-0.5 grid gap-0.5 lg:grid-cols-[15rem_minmax(0,1fr)]">
|
||||
{isAdmin ? (
|
||||
<nav className="hidden space-y-0.5 lg:block" aria-label="Administration sections">
|
||||
{TOP_LEVEL_SECTIONS.filter((entry) => !entry.lockdownOnly || isLockdown).map((entry) => (
|
||||
<NavigationButton key={entry.key} active={selected === entry.key} onClick={() => selectSection(entry.key)}>{entry.label}</NavigationButton>
|
||||
))}
|
||||
<Tabs currentTab={activeSection} onTabChange={selectSection}>
|
||||
{/* Reusing the same responsive tab surface as the driver page makes
|
||||
administration feel like another MultiRover workspace instead
|
||||
of a separate desktop-oriented application. */}
|
||||
<nav aria-label="Administration sections">
|
||||
<TabList className="my-0.5">
|
||||
{navigationOptions.map((entry) => <Tab key={entry.key} id={entry.key}>{entry.label}</Tab>)}
|
||||
</TabList>
|
||||
</nav>
|
||||
) : null}
|
||||
<section className="min-w-0">{content}</section>
|
||||
</div>
|
||||
<TabPanels><section className="min-w-0">{content}</section></TabPanels>
|
||||
</Tabs>
|
||||
) : <section className="mt-0.5 min-w-0">{content}</section>}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 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 CardFrame from '../../components/CardFrame/index.jsx';
|
||||
import { updateConfiguration } from '../api.js';
|
||||
import SchemaConfigurationForm from './SchemaConfigurationForm.jsx';
|
||||
|
||||
@@ -63,12 +64,12 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
|
||||
|
||||
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">
|
||||
<CardFrame className="sticky top-0 z-20 bg-neutral-900/95 backdrop-blur" title="Configuration" meta={`revision ${revision}`} bodyClassName="space-y-0.5 p-0.5">
|
||||
<p className="text-[0.7rem] text-slate-400">Saved changes apply after an application restart.</p>
|
||||
{/* All document actions stay together at the start of the toolbar. The
|
||||
editor may use a wide canvas, but width is never used to separate a
|
||||
control from the content that explains it. */}
|
||||
<div className="flex flex-wrap 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));
|
||||
@@ -76,7 +77,7 @@ export default function ConfigurationEditor({ snapshot, socket, runSensitive, on
|
||||
}}>Reset</button>
|
||||
<button type="button" className="button-dark" disabled={!dirty || saving} onClick={save}>{saving ? 'Saving…' : 'Save configuration'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardFrame>
|
||||
{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">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// 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.
|
||||
// Purpose: Renders the server-provided JSON Schema in the same cards, surfaces, fields, and buttons as the rest of MultiRover.
|
||||
// Scope: Defines one generic RJSF presentation; it never names or special-cases an individual service or setting.
|
||||
import { useMemo } from 'react';
|
||||
import Form from '@rjsf/core';
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||
|
||||
function buildUiSchema(schema, path = '') {
|
||||
/*
|
||||
@@ -41,7 +42,9 @@ function SecretWidget({ id, disabled, readonly, options, registry }) {
|
||||
|
||||
return (
|
||||
<div className="surface space-y-0.5 p-0.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-0.5">
|
||||
{/* Secret actions stay beside their status. A wide configuration card
|
||||
must not turn related controls into a trip across the screen. */}
|
||||
<div className="flex flex-wrap items-center 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>
|
||||
@@ -70,9 +73,143 @@ function SecretWidget({ id, disabled, readonly, options, registry }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigurationFieldTemplate({
|
||||
children,
|
||||
classNames,
|
||||
description,
|
||||
displayLabel,
|
||||
errors,
|
||||
help,
|
||||
hidden,
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
schema,
|
||||
style,
|
||||
}) {
|
||||
if (hidden) return <div className="hidden">{children}</div>;
|
||||
|
||||
const containerField = schema?.type === 'object' || schema?.type === 'array';
|
||||
if (containerField) {
|
||||
// Objects and arrays own their visible boundaries and headings in the
|
||||
// templates below. Keeping this wrapper structural avoids duplicated
|
||||
// titles while retaining RJSF's useful field-type classes for layout.
|
||||
return <div className={classNames} style={style}>{children}{errors}{help}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${classNames || ''} configuration-field surface p-1`} style={style}>
|
||||
{displayLabel ? (
|
||||
<label htmlFor={id} className="block text-xs font-semibold text-slate-100">
|
||||
{label}{required ? <span className="ml-0.25 text-sky-300">*</span> : null}
|
||||
</label>
|
||||
) : null}
|
||||
{displayLabel && description ? <div className="mt-0.25">{description}</div> : null}
|
||||
<div className={displayLabel ? 'mt-0.5' : ''}>{children}</div>
|
||||
{errors}
|
||||
{help}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigurationObjectTemplate({ description, fieldPathId, properties, title }) {
|
||||
const visibleProperties = properties.filter((property) => !property.hidden);
|
||||
const propertyGrid = (
|
||||
<div className="configuration-property-grid grid gap-0.5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{/* RJSF gives each property content its own keyed field wrapper. Rendering
|
||||
it directly preserves field-object and field-array on the grid child,
|
||||
allowing containers to span the row without another frontend schema. */}
|
||||
{visibleProperties.map((property) => property.content)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fieldPathId.path.length === 0) {
|
||||
// The editor toolbar already identifies the root document. The root is a
|
||||
// simple ordered stack so every service-owned top-level object receives
|
||||
// the full page width before arranging its own fields responsively.
|
||||
return <div className="space-y-1">{visibleProperties.map((property) => property.content)}</div>;
|
||||
}
|
||||
|
||||
if (typeof fieldPathId.path.at(-1) === 'number') {
|
||||
// Array items receive their numbered heading and action row from the array
|
||||
// item template. Rendering only their property grid prevents redundant
|
||||
// nested boxes such as "Item 1" followed by another anonymous object box.
|
||||
return propertyGrid;
|
||||
}
|
||||
|
||||
if (fieldPathId.path.length === 1) {
|
||||
return (
|
||||
<CardFrame title={title} clipOverflow={false} bodyClassName="space-y-0.5 p-0.5">
|
||||
{description ? <div className="px-0.5 text-xs text-slate-400">{description}</div> : null}
|
||||
{propertyGrid}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="configuration-object surface border border-neutral-500/60 p-0.5">
|
||||
<h3 className="mb-0.5 text-sm font-semibold text-slate-100">{title}</h3>
|
||||
{description ? <div className="mb-0.5 text-xs text-slate-400">{description}</div> : null}
|
||||
{propertyGrid}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigurationArrayItemTemplate({ buttonsProps, children, hasToolbar, index }) {
|
||||
const unavailable = buttonsProps.disabled || buttonsProps.readonly;
|
||||
|
||||
return (
|
||||
<article className="surface-muted space-y-0.5 border border-neutral-500/60 p-0.5">
|
||||
{hasToolbar ? (
|
||||
// Text controls are intentionally kept immediately after the item
|
||||
// number. RJSF's default Bootstrap toolbox pushes empty glyphicon
|
||||
// buttons to the far edge, which is both unclear and hard to reach.
|
||||
<header className="flex flex-wrap items-center gap-0.5">
|
||||
<span className="mr-0.5 text-xs font-semibold text-slate-100">Item {index + 1}</span>
|
||||
{(buttonsProps.hasMoveUp || buttonsProps.hasMoveDown) ? (
|
||||
<button type="button" className="button-dark text-xs" disabled={unavailable || !buttonsProps.hasMoveUp} onClick={buttonsProps.onMoveUpItem}>Move up</button>
|
||||
) : null}
|
||||
{(buttonsProps.hasMoveUp || buttonsProps.hasMoveDown) ? (
|
||||
<button type="button" className="button-dark text-xs" disabled={unavailable || !buttonsProps.hasMoveDown} onClick={buttonsProps.onMoveDownItem}>Move down</button>
|
||||
) : null}
|
||||
{buttonsProps.hasCopy ? (
|
||||
<button type="button" className="button-dark text-xs" disabled={unavailable} onClick={buttonsProps.onCopyItem}>Duplicate</button>
|
||||
) : null}
|
||||
{buttonsProps.hasRemove ? (
|
||||
<button type="button" className="button-danger text-xs" disabled={unavailable} onClick={buttonsProps.onRemoveItem}>Remove</button>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
{children}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigurationArrayTemplate({ canAdd, disabled, items, onAddClick, readonly, schema, title }) {
|
||||
return (
|
||||
<section className="configuration-array surface space-y-0.5 border border-neutral-500/60 p-0.5">
|
||||
<header className="flex flex-wrap items-center gap-0.5">
|
||||
<h3 className="text-sm font-semibold text-slate-100">{title}</h3>
|
||||
<span className="text-[0.7rem] text-slate-400">{items.length} {items.length === 1 ? 'item' : 'items'}</span>
|
||||
</header>
|
||||
{schema.description ? <div className="text-xs text-slate-400">{schema.description}</div> : null}
|
||||
{items.length ? <div className="space-y-0.5">{items}</div> : <p className="text-xs text-slate-500">No items configured.</p>}
|
||||
{canAdd ? (
|
||||
<button type="button" className="button-dark text-xs" disabled={disabled || readonly} onClick={onAddClick}>Add item</button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchemaConfigurationForm({ schema, value, onChange, configuredSecrets, secretOperations, setSecretOperation }) {
|
||||
const uiSchema = useMemo(() => buildUiSchema(schema), [schema]);
|
||||
const widgets = useMemo(() => ({ SecretWidget }), []);
|
||||
const templates = useMemo(() => ({
|
||||
ArrayFieldItemTemplate: ConfigurationArrayItemTemplate,
|
||||
ArrayFieldTemplate: ConfigurationArrayTemplate,
|
||||
FieldTemplate: ConfigurationFieldTemplate,
|
||||
ObjectFieldTemplate: ConfigurationObjectTemplate,
|
||||
}), []);
|
||||
const formContext = useMemo(() => ({
|
||||
configuredSecrets,
|
||||
secretOperations,
|
||||
@@ -87,6 +224,7 @@ export default function SchemaConfigurationForm({ schema, value, onChange, confi
|
||||
formData={value}
|
||||
validator={validator}
|
||||
widgets={widgets}
|
||||
templates={templates}
|
||||
formContext={formContext}
|
||||
noHtml5Validate
|
||||
showErrorList={false}
|
||||
|
||||
+13
-34
@@ -66,36 +66,9 @@ body::-webkit-scrollbar,
|
||||
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-rose-600 hover:bg-rose-500 rounded-md;
|
||||
}
|
||||
|
||||
/*
|
||||
The configuration editor is generated by one JSON Schema renderer. These
|
||||
rules style its standard semantic HTML once; individual services never
|
||||
need React components or UI-specific class names for their settings.
|
||||
*/
|
||||
.configuration-schema-form form > fieldset {
|
||||
@apply space-y-0.5 border-0 p-0;
|
||||
}
|
||||
|
||||
.configuration-schema-form fieldset fieldset {
|
||||
@apply my-0.5 space-y-0.5 border-l-2 border-sky-800/70 bg-neutral-900/70 py-0.5 pl-1 pr-0.5;
|
||||
}
|
||||
|
||||
.configuration-schema-form legend,
|
||||
.configuration-schema-form h1,
|
||||
.configuration-schema-form h2,
|
||||
.configuration-schema-form h3,
|
||||
.configuration-schema-form h4,
|
||||
.configuration-schema-form h5 {
|
||||
@apply mb-0.5 font-mono text-sm font-semibold text-sky-300;
|
||||
}
|
||||
|
||||
.configuration-schema-form .form-group {
|
||||
@apply mb-0.5;
|
||||
}
|
||||
|
||||
.configuration-schema-form label {
|
||||
@apply mb-0.5 block text-xs font-semibold text-slate-200;
|
||||
}
|
||||
|
||||
/* RJSF still owns values, validation, and schema traversal, while the
|
||||
generic React templates own layout. Only native form-control appearance
|
||||
remains here, shared uniformly by every present and future setting. */
|
||||
.configuration-schema-form input:not([type='checkbox']),
|
||||
.configuration-schema-form select,
|
||||
.configuration-schema-form textarea {
|
||||
@@ -103,7 +76,11 @@ body::-webkit-scrollbar,
|
||||
}
|
||||
|
||||
.configuration-schema-form input[type='checkbox'] {
|
||||
@apply mr-0.5 h-4 w-4 accent-sky-500;
|
||||
@apply mr-0.5 h-4 w-4 align-middle accent-sky-500;
|
||||
}
|
||||
|
||||
.configuration-schema-form .checkbox label {
|
||||
@apply flex cursor-pointer items-center gap-0.5 text-sm font-semibold text-slate-100;
|
||||
}
|
||||
|
||||
.configuration-schema-form .field-description,
|
||||
@@ -115,8 +92,10 @@ body::-webkit-scrollbar,
|
||||
@apply mt-0.5 text-xs text-red-300;
|
||||
}
|
||||
|
||||
.configuration-schema-form button:not(.button-dark):not(.button-danger) {
|
||||
@apply mx-0.5 rounded-md border border-sky-300 bg-sky-600 px-0.5 py-0.5 text-xs font-medium text-white transition-colors hover:border-sky-500 hover:bg-sky-500;
|
||||
/* Container fields need the complete row in responsive property grids.
|
||||
Primitive fields remain free to share columns where their parent has room. */
|
||||
.configuration-property-grid > .field-object,
|
||||
.configuration-property-grid > .field-array {
|
||||
@apply col-span-full;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user