A React + TypeScript admin console that discovers backend services entirely
from a metadata document (schema.json) each service exposes. The frontend
holds no knowledge of any specific service, resource, or field — install a
service, and its resources, table columns, actions, and realtime updates all
come from what it declares.
CRUD data-fetching, pagination, and per-record/collection actions are now
implemented on top of @ferrumec/dashboard (in packages/dashboard)
rather than hand-rolled react-query hooks and a manually-built
@tanstack/react-table instance. See Architecture below for what that
changed.
npm install # resolves packages/dashboard via the npm workspace
npm run devRequires Node 18+. This was written without network access to run npm install in the sandbox it was built in, so if a dependency version resolves
to something incompatible, loosen the pin in package.json and reinstall.
A tiny mock backend implementing examples/example-schema.json is included
so you can see the console work end-to-end without wiring up a real service
first.
npm i -D ws
node examples/mock-server.mjsThen, in the console, click Install service and enter:
http://localhost:4001/schema.json
It emits a random product price update over WebSocket every few seconds — watch the row update live with no refresh.
Any backend can plug in by exposing:
- A
schema.json(or any path — you supply the URL) matching the contract insrc/types/metadata.ts. This wire format is unchanged — it's the console's own service-discovery schema, separate from@ferrumec/dashboard'sResourceSchema/ItemSchema.src/lib/resourceAdapter.tsis what translates one into the other. - The REST endpoints it declares under
endpoints(list is required for a resource to render; create/update/delete/subscribe are all optional). - Optionally, a WebSocket endpoint that broadcasts
{ topic, operation, key, data }messages for realtime resources.
For a Rust/Actix backend, the schema.json can be served as a static file
or generated from route metadata; the endpoints it points to are just your
normal REST handlers — nothing console-specific is required on the backend
beyond returning { data, total } (or a plain array) from list endpoints.
src/types/metadata.ts— the metadata contract fetched from each installed service. Unchanged from before — every other module is typed against this, never against a concrete resource shape.src/lib/metadataValidation.ts— validates and rejects malformed or version-mismatchedschema.jsondocuments before they reach the UI. Unchanged.src/lib/api.ts— now justfetchServiceMetadata/ApiError. The oldfetchResourceList/executeAction/deleteRoware gone; that logic now lives inside the generatedResourceApi/ItemApi/actions built byresourceAdapter.ts.src/lib/resourceAdapter.ts(new) — the bridge. Turns one declared resource (fields,endpoints,actions,capabilities) into a real@ferrumec/dashboardResource: field metadata (withformatreusing the existingcellRenderers.tsxso currency/status/image/etc. render exactly as before), aResourceApiwired to the declared REST endpoints with framework-native pagination, and anActionMapper declaredResourceAction(row-scoped actions get a per-record map viaitemActions, global ones viaactions). This is the only place in the app that callsfetchfor CRUD.src/lib/resourceRegistry.ts(new) — caches oneResourceControllerper (service, resource), rebuilt if the declared schema changes (e.g. viaMetadataLoader's background refresh).src/store/useAdminStore.ts— simplified. Per-resource table UI state (page/pageSize/search/sort) is gone entirely — eachResourceControllernow owns that itself. The store is back to just installed services and the current selection.src/components/table/ResourceListLayout.tsx(new) — aListLayoutComponentfor@ferrumec/dashboard, replacingGenericTable.tsx+columns.tsx+TableToolbar.tsx. Reuses the samecellRenderers.tsx,SearchBar.tsx,Pagination.tsx, andActionButtonUI as before; sortable column headers now drive the framework'sactions.setQuery.src/components/table/cellRenderers.tsx— unchanged; still renders a cell from nothing but a field's declaredtype.src/components/actions/actionExecutor.ts/ActionButton.tsx— decoupled fromservice/endpoint construction. They only handle confirm/pending/error UX now;runis whatever the framework generated (actions.runAction/actions.runItemAction).src/pages/ResourcePage.tsx— now just looks up a cachedResourceControllerand renderscontroller.ListView({ layout: ResourceListLayout }). Realtime events debounce into acontroller.refresh()rather than patching a react-query cache directly — see the comment in that file for why.src/hooks/useUrlSync.ts— unchanged; keeps/services/:serviceId/:resourceNamein the address bar in sync with the store.
- Realtime is coarser. The old
applyRealtimeEventpatched the react-query cache in place per event.@ferrumec/dashboard's Resource store isn't mutable from outside a Resource — onlyrefresh()is public — so a realtime event now triggers a debounced full refetch of the current page instead. Fine for the mock server's occasional price update; worth revisiting if a service pushes high-frequency events. - The resource-controller cache doesn't evict.
resourceRegistry.tskeys on a schema fingerprint so a background metadata refresh gets a fresh controller, but old entries stay in theMap. Not a problem for a console with a handful of installed services in one session. - No detail/update views yet. The console never had them (table +
actions only), so the rewrite doesn't add them — but
resource.item(id)now exists on everyResourceController, so wiring upDetailView/UpdateViewfor a "row → detail drawer" feature is a much smaller change than it would have been before.
- Forms / detail drawers —
resource.item(id).DetailView()/.UpdateView()are already available from@ferrumec/dashboard; write a layout component (mirroringResourceListLayout.tsx) and wire it to a row click inResourceListLayout'snavigation.viewItem. - Bulk actions / CSV export —
resource.capabilities.exportis already read byResourceListLayout; wire the button to a CSV serializer over the current page, or declare it as a globalResourceActionand it'll show up automatically viaactions.runAction. - Auth — a fetch wrapper in
resourceAdapter.tsis the single choke point for adding auth headers/token refresh across every service. - Multiple realtime transports —
ResourceRealtime.transportis already a discriminant; add an SSE implementation alongsidewebsocketManager.tsand branch on it inuseRealtimeResource.ts.