Frontend
This documentation describes in detail the architecture, components, and state management of the frontend (server/frontend). The application is a Single Page Application (SPA) based on React, Vite, TypeScript, and Tailwind CSS.
π Project Structure¶
The structure follows a Feature-First Approach, where code belonging to a specific domain area is grouped together.
src/
βββ features/
β βββ app/ # Application shell
β β βββ App.tsx # The providers around the router
β β βββ router.tsx # createBrowserRouter: /login, and the shell behind the session
β β βββ routes.tsx # The route tree: paths, sidebar entries, titles, breadcrumb, error elements
β β βββ routeElements.tsx # What the tree renders; ClientBoundary, the legacy redirects
β β βββ routeContext.ts # useRouteClient: the client a route below /clients/:clientId is about
β β βββ lazyPages.ts # The page components, loaded on demand
β β βββ AppLayout.tsx # The dashboard shell; the page is its Outlet
β β βββ HeaderBreadcrumb.tsx # The trail as the heading of a page's first card
β β βββ RouteError.tsx # The areas' errorElement: not-found card or the error itself
β β βββ context/
β β βββ WebSocketContext.ts # WebSocket context object and useWebSocket hook
β β βββ WebSocketProvider.tsx # WebSocket connection for real-time updates
β β βββ PullContext.ts # usePull: every pull & recreate asks and is sent through it
β β βββ PullProvider.tsx # The pull dialog (with a forced pull for a project) and the sending
β βββ auth/
β β βββ AuthContext.ts # Auth context object and useAuth hook
β β βββ AuthProvider.tsx # Authentication state
β βββ dashboard/ # The overview at /
β β βββ lib/dashboard.ts # The counts the cards and the sidebar badges both show (pure)
β β βββ components/DashboardOverview.tsx # One StatCard per count, each the way to its list
β βββ clients/ # Client management
β β βββ confirmations.ts # Remove, delete-client, re-register and discard texts
β β βββ dockerRemove.ts # The actions that ask before they are sent
β β βββ onlineTone.ts # The StatusDot tone of something that is live or not
β β βββ components/
β β βββ ManagedClients.tsx # Container for client list & actions
β β βββ ClientList.tsx # Paginated client data table
β β βββ ClientOverview.tsx # Detail view for a single client (tabs)
β β βββ ClientIdentityCard.tsx # The client's own fields, edited and saved in place
β β βββ ClientEditor.tsx # Form for editing a client
β β βββ ClientLabel.tsx # Dot and name of a client, for the rows that name one
β β βββ ClientContainerList.tsx # Containers tab in ClientOverview
β β βββ ClientImageList.tsx # Images tab in ClientOverview
β β βββ ClientVolumeList.tsx # Volumes tab in ClientOverview
β β βββ ClientNetworkList.tsx # Networks tab in ClientOverview
β β βββ RegistrationTokenModal.tsx # An inbound client's registration token, shown once
β β βββ add-client/ # One wizard for both connection modes
β β βββ AddClientWizard.tsx # Mode choice, then the inbound or outbound branch
β β βββ useAddClientForm.ts # Form state, held above the wizard
β β βββ steps/ # StepConnectionMode, StepInboundDetails, StepOutboundDetails
β βββ containers/ # Cross-client container view
β β βββ activityFilter.ts # Which activity events belong to a container page
β β βββ autoUpdate.ts # Why a container takes part: label, project, or not at all
β β βββ confirmations.ts # Remove-container text
β β βββ containerState.ts # State dot tones and the page path of a row
β β βββ instanceDetails.tsx # The client, container and image groups of the instance pages
β β βββ components/
β β β βββ ManagedContainers.tsx # Tree-grouped containers with filters and per-row actions
β β β βββ ContainerOverview.tsx # Detail view of one container and its instances
β β β βββ ContainerInstanceOverview.tsx # One instance: the container on one client, with its activity
β β β βββ ContainerStatus.tsx # The docker-ps status text, derived and kept counting
β β β βββ AutoUpdateSourceCell.tsx # Renders that reading, shared by both container lists
β β βββ lib/
β β β βββ containerGroups.ts # Every container of the fleet, grouped by name and image (pure)
β β β βββ filterContainers.ts # Search and the state and update filters, on host rows (pure)
β β βββ hooks/
β β βββ useContainersData.ts # Reads the cache and calls buildContainerGroups
β β βββ useContainerActions.ts # Check, pull, start, stop, remove and their menu entries
β β βββ useAutoUpdateRuns.ts # The newest autoupdate.run event per client
β β βββ useAutoUpdateRunToasts.ts # Speaks for a run from the shell, minutes later
β βββ images/ # Cross-client image view
β β βββ activityFilter.ts # Which events belong to an image page on one or all clients
β β βββ confirmations.ts # PullRequest and its text (describePull), prune texts
β β βββ components/
β β β βββ ManagedImages.tsx # Repository β Tag β Digest tree view
β β β βββ ImageRepositoryList.tsx # Repository-level rows
β β β βββ ImageList.tsx # Per-tag rows
β β β βββ ImageContainerList.tsx # Containers using a tag
β β β βββ ImageOverview.tsx # Detail view with stats and tables
β β β βββ ImageInstanceOverview.tsx # One reference on one client, its containers and activity
β β β βββ ClientImageOverview.tsx # One image on one client by id: header and activity
β β β βββ CheckLabel.tsx # "Check for updates" of a list header; "Check" on a phone
β β β βββ UpdateIcon.tsx # Update status: glyph, tooltip and accessible name
β β βββ hooks/
β β β βββ useImagesData.ts # Reads the cache and calls buildImageTree
β β β βββ useImageNodeActions.ts # Check and pull for a node -- list rows and page alike
β β β βββ useConfirmCheck.ts # The question in front of every list header's check, and the check itself
β β βββ lib/
β β βββ digest.ts # Digest, image-id and reference normalisation, "is a check running"
β β βββ imageTree.ts # The repository β tag β digest tree of the fleet (pure)
β β βββ updateStatus.ts # What a registry check says about an image, the worst of several, its name
β β βββ nodeStatus.ts # What a tree node allows: check, pull, recreate
β βββ projects/ # Query-defined container groups as a management unit
β β βββ confirmations.ts # Remove-project text
β β βββ query.ts # Labels, suggestions and the readable form of a query
β β βββ components/
β β β βββ ManagedProjects.tsx # List, update column and actions, edit and delete
β β β βββ ProjectEditor.tsx # Create/edit: name, query, auto-update, live result
β β β βββ QueryBuilder.tsx # The criteria rows with AND/OR, reordering, suggestions
β β β βββ QueryResultTable.tsx # What the query matches right now, with conflicts
β β β βββ ProjectOverview.tsx # One project: query and settings in the header, members in tabs
β β β βββ ProjectImages.tsx # Images tab: image β the containers that run it
β β β βββ ProjectClients.tsx # Clients tab: host β its containers, with updates
β β β βββ ProjectPullButton.tsx # Pull & Recreate in each tab's header, over what the tab reaches
β β βββ pullPlan.ts # What a project's pull sends, per mode, and its PullRequest
β β βββ activityFilter.ts # Which activity events belong to a project
β β βββ lib/
β β β βββ projectMembers.ts # Host states, container β project assignment, members, targets (pure)
β β βββ hooks/
β β βββ useProjectMembers.ts # Reads the cache and calls the three functions above
β βββ activity/ # What happened, as structured events
β β βββ confirmations.ts # Delete-all text
β β βββ components/
β β β βββ ActivityGroupSteps.tsx # The members of one correlated group
β β β βββ ActivityView.tsx # The page at /activity, and the activity of a project or container page
β β βββ lib/
β β βββ activityLinks.ts # Where the chips of an event lead
β β βββ collapseRepeats.ts # Folds neighbouring rows that repeat each other
β β βββ groupActivity.ts # Folds the flat list into rows by correlationId
β βββ users/ # User management
β β βββ confirmations.ts # Delete-user and last-user texts
β β βββ components/
β β βββ UserOverview.tsx
β β βββ UserList.tsx
β β βββ UserDialog.tsx
β βββ settings/ # The settings page's sections
β β βββ sections.ts # The four tabs and the keys each one saves
β β βββ components/
β β βββ SettingsSections.tsx # One component per section
β β βββ SettingsParts.tsx # ManualRun, reading a failure the way the app does
β βββ webhooks/ # Webhooks: list and editor, each a page
β βββ confirmations.ts # Delete-webhook and discard texts
β βββ lib/webhookForm.ts # Draft <-> API shape, preview, placeholder list
β βββ components/
β βββ WebhookOverview.tsx # The page at /webhooks: loads, toggles, deletes, opens the editor
β βββ WebhookList.tsx # DataMultiView of the targets and their last delivery
β βββ WebhookEditor.tsx # /webhooks/new and /webhooks/:id, with live preview and "Send Test"
βββ components/
β βββ NotFoundCard.tsx # A page whose subject does not exist, with the way back
β βββ RelativeTime.tsx # "2 h ago" with the date in the tooltip, on the tick of useNow
β βββ entityHeader.ts # A detail page's header: the size of its breadcrumb title
βββ hooks/
β βββ useSearchQueryParam.ts # Search box and active tab, held in the URL
β βββ useNow.ts # One shared clock for durations that keep counting
β βββ useSearchHotkey.ts # "/" focuses the search of the list on screen
β βββ useEscapeToLeave.ts # Escape on a detail page leads back, unless a field has focus
β βββ useBackPath.ts # Where closing a page leads: its parent in the route tree
β βββ useEntityForm.ts # An editor's draft, its baseline and its save, checked against a schema
β βββ useUnsavedChangesGuard.ts # One question for every way out of a changed editor
β βββ useDockerClientLookup.ts # Container/image β the client it lives on
β βββ useDockerActions.ts # Check, pull, start, stop: a refusal becomes a toast
β βββ useBatch.ts # A bulk action in one toast: a bar, then the outcome
βββ lib/
β βββ api.ts # The API client: every response parsed against its schema
β βββ apiFetch.ts # fetch with the session attached, central 401 handling
β βββ queryClient.ts # The one TanStack Query cache; readUnlessPushed
β βββ queryKeys.ts # Every key the cache is addressed by
β βββ cacheUpdates.ts # How a message or an answer changes a cache entry (pure)
β βββ paths.ts # Every path once, builders, the legacy patterns, the container group id
β βββ storageKeys.ts # Every key in the browser's storage, once: dim.<area>.<what>
β βββ listScope.ts # What a header button over a list reaches: "selected", "all" or "shown" -- said by the check's question
β βββ backPath.ts # The parent of a chain of route matches (pure)
β βββ notFound.ts # NotFoundError, thrown by a route whose subject is gone
β βββ pageTitle.ts # The document title from the handles of the open route (pure)
β βββ breadcrumb.ts # The trail to the open route, from the same handles (pure)
β βββ entityForm.ts # The rules a form is checked by: field errors, sameness of drafts (pure)
β βββ hostResults.ts # One action on several hosts: every refusal, by host
β βββ batchToast.ts # What a bulk action's toast says, running and at the end (pure)
β βββ pendingImages.ts # Checks and pulls under way, from the pending mutations
βββ pages/ # Route entry points
β βββ Login.tsx # Authentication page (Local & OIDC)
β βββ Settings.tsx # System settings page
βββ queries/ # Server data: one module per kind (TanStack Query)
β βββ clients.ts # Registered clients and online/offline status
β βββ docker.ts # Per-client Docker states, actions and update checks
β βββ activity.ts # The activity list and the per-user seen state
β βββ projects.ts # Managed projects with their members
β βββ autoUpdate.ts # The configured auto-update label
β βββ scheduler.ts # Status of the server's schedulers
β βββ webhooks.ts, users.ts, tokens.ts # Their lists
βββ stores/
β βββ useUIStore.ts # UI state (sidebar collapse, persisted) -- the only store
βββ utils.ts # General utility functions
π¦ Routing & Navigation¶
Routing is a data router (createBrowserRouter, react-router-dom v7). features/app/routes.tsx describes everything inside the shell as one tree, and five things are read off it instead of being written down again:
- Paths.
lib/paths.tsholds every pattern once (ROUTES) and a builder for each pattern with parameters (paths.client(id)). No path literal anywhere else;generatePathdoes the encoding. - The sidebar. An area carries its entry in
handle.nav.AppLayoutmarks the entry of the innermost match that has one, so an entry stays marked while any route below its area is open. On a phone the same entries are the bar at the bottom, each with its name under the icon (bottomNavLabels). Six names fit that width, so the entry of Images and those of the administration carryplacement: "mobile-more"and sit in the sheet behind "More". - Back. Closing a page leads to its parent in the tree (
useBackPath, overparentPathinlib/backPath.ts). It is read from the URL alone, so a reloaded editor closes onto the same page as a clicked one. It used to belocation.state.from, which a reload lost. - The document title.
routeTitleinlib/pageTitle.tsjoins the handles along the open route, most specific first:Edit Β· web01 Β· Clients Β· DIM. - The breadcrumb.
breadcrumbinlib/breadcrumb.tsreads the same handles outermost first and gives each the address of its route:Clients βΊ web01 βΊ Edit. It names a page withownNamefromlib/pageTitle.ts, so trail and title cannot disagree. Every link but the last leads somewhere; a page with nothing above it (a list, the overview) has no trail.AppLayouthands it to the pages throughBreadcrumbContext, and the detail pages and every editor show it as the heading of their first card, in place of the title (features/app/HeaderBreadcrumb.tsx); below thesmbreakpoint the page keeps its heading behind aβΉthat leads to the link above it (parentCrumb). The links are router links, so leaving a changed editor through one asks like every other way out. An instance route (handle.onHost) sits beside its subject's page in the tree, not below it, so its trail is told where that page is:Containers βΊ authelia βΊ auth.internal, the middle link being the container across all hosts.
| Path | Element | Description |
|---|---|---|
/login |
LoginRoute |
Authentication page (Local & OIDC). |
/ |
DashboardOverview |
The overview: what needs a look, across every host. |
/clients |
ClientsRoute |
Registered clients overview. |
/clients/new |
AddClientRoute |
The AddClientWizard. |
/clients/:clientId |
ClientDetailRoute |
Detail view of a specific client (containers/images/volumes/nets). |
/clients/:clientId/edit |
ClientEditRoute |
The ClientEditor for that client. |
/clients/:clientId/images/:imageId |
ClientImageRoute |
One image on one client by id (untagged ones too), with its activity. |
/projects |
ManagedProjects |
Managed projects across all clients. |
/projects/new |
ProjectEditor |
Add a project: name and query. |
/projects/:projectId |
ProjectDetailRoute |
One project: its query, settings and members. |
/projects/:projectId/edit |
ProjectEditRoute |
Edit a project in the same editor. |
/containers |
ManagedContainers |
Aggregated containers across all clients. |
/containers/:containerId |
ContainerDetailRoute |
One container (name + image) and its instances on every client. |
/containers/instances/:clientId/:containerName |
ContainerInstanceRoute |
One instance: a container on one client, with its activity. |
/images |
ManagedImages |
Aggregated images as a Repository β Tag β Digest tree. |
/images/:imageId |
ImageDetailRoute |
Image detail view (stats, containers using it). |
/images/instances/:clientId/:imageRef |
ImageInstanceRoute |
One image reference on one client, with its containers and activity. |
/activity |
ActivityView |
The activity list. |
/users |
UserOverview |
User management. |
/webhooks |
WebhookOverview |
The webhooks events are reported to. |
/webhooks/new, /webhooks/:webhookId |
WebhookEditorRoute |
The WebhookEditor, adding or editing one webhook. |
/settings |
Settings |
System settings (retention policies, image cache, etc.). |
All routes except /login are children of one layout route: ProtectedRoute, which redirects unauthenticated users to /login, around AppLayout, which renders the Dashboard from @stefgo/react-ui-components with the page as its Outlet. The library's Dashboard renders only the navigation; its pages are built from navEntries (the areas of the tree), with what only the running application knows added by id -- the client count, the number of containers with an update and the dot for unseen activity. Navigation is organised into navGroups (overview, resources, activity, admin).
Every area takes the plural of its list. Nesting needs a child's path to start with its parent's, and the mix of /clients and /client/:clientId did not allow it. The instance pages sit below Containers and Images rather than below their client, because they are opened from those lists and theirs is the sidebar entry to mark; App.tsx used to force that with matchPath. The one page below a client is /clients/:clientId/images/:imageId, which only the client's image list opens.
The previous addresses redirect. LEGACY_ROUTES in lib/paths.ts pairs each old pattern (/client/:clientId, /project/:projectId, /container/:containerId, /image/:imageId and what lay below them) with its replacement, and LegacyRedirect fills the new pattern with the parameters the old one matched, query included. They are kept for one release.
Each area has an errorElement (RouteError). A render error replaces the page and leaves the shell standing. A subject that does not exist is the same mechanism: ClientBoundary, the project pages and the webhook editor throw NotFoundError once their list has answered, and RouteError shows the card with the way back. The container and image pages render their own NotFoundCard instead: the router keeps an error element until the next navigation, and a container leaves the fleet state for the moment a recreate takes, so those pages must be able to show their subject again.
A path nothing matches reaches the catch-all route and renders a 404 card that names the path and leads back to the clients view.
The pages are loaded on demand (React.lazy with a Suspense fallback), so a chunk arrives with the route that needs it. The previous shape passed every page as an element to the Dashboard, which built the tree of all nine on every render of the shell even though one was on screen.
Each route takes what it needs from the cache itself. ClientBoundary, the layout route at /clients/:clientId, resolves the client once for the routes below it and hands it down as the outlet context (useRouteClient). While the client list is pending it shows a spinner -- a link to a client arrives before the list does -- and only after that is a missing client not found. ImageDetailRoute and ContainerDetailRoute pass their :imageId / :containerId parameter to the page.
π Authentication¶
Authentication is managed by the AuthProvider (src/features/auth/AuthProvider.tsx); components read it through useAuth from AuthContext.ts.
Each context is split the same way: the context object and its hook live in a JSX-free .ts module, the provider component in a .tsx file of its own. A module that exports a component next to a hook cannot be swapped by Vite's Fast Refresh, and react-refresh/only-export-components reports it as an error.
- Session: The JWT never reaches JavaScript. The server keeps it in the httpOnly cookie
dim_session, which the browser sends with every request and with the WebSocket handshake. The page only reads the flag cookiedim_auth, which carries no secret, to decide whether to render the login form. - Provider: The
AuthProviderwraps the app and providesisAuthenticated,user({ id, username }fromGET /api/v1/me,nulluntil it answers),login()andlogout().logout()clears the flag, callsPOST /api/auth/logoutto remove the httpOnly cookie, and returns to/login. - Login Flow:
- Local: POST to
/api/loginβ the server sets the cookies βlogin(). - OIDC: Redirect to
/api/auth/loginβ provider callback with code β the backend exchanges the code, sets the cookies and redirects to/. Nothing is passed in the URL.
- Local: POST to
- Stale flag: The flag can outlive the session (a restarted server with a new
jwtSecret, an expired token). The first request,/api/v1/me, then answers401and the API client logs out. - API calls: Every request to an authenticated endpoint goes through
api(src/lib/api.ts), which sends it withapiFetch(src/lib/apiFetch.ts):credentials: "same-origin", so the session cookie goes along, and one reaction to401β it calls thelogouttheAuthProviderregistered withsetUnauthorizedHandlerand throwsSessionExpiredError, so the router lands on/login. Queries and components therefore take no token parameter.Login.tsxusespublicApion purpose β/api/loginand/api/auth/configare unauthenticated, and a wrong password must produce an error message, not a logout. - Expiry: Besides the
401handling, theAuthProviderlogs out at theexpiresAtthat/api/v1/mereports, because a dashboard fed only by the WebSocket may not send a request for a long time. - Login UI: The
Login.tsxpage uses the pre-builtLoginPagecomponent from@stefgo/react-ui-components, configured with app title, auth type, and handler callbacks.
ποΈ State Management¶
Two kinds of state, kept apart.
What the server holds lives in one TanStack Query cache (lib/queryClient.ts), read through the modules in queries/. What only this browser knows lives in Zustand: useUIStore, the sidebar's collapse state, saved to localStorage by the persist middleware. It is the only store. Its key, the theme's and those of every list's view settings are named in lib/storageKeys.ts and nowhere else, as dim.<area>.<what>.
The API client (lib/api.ts)¶
Every request goes through api.get, api.post, api.put, api.patch or api.delete, and nothing else reads a response body.
- Every call names the schema its answer has to match (
shared/src/responses.ts) and gets back what that schema parsed. An answer that does not match throws; the issues go to the console, because they name fields. - A refusal throws an
ApiErrorwith the server's ownerrortext and the HTTP status. A caller passes afallbackfor a body that carries none. publicApiis the same client for/api/login,/api/auth/logoutand/api/auth/config, where a401is a wrong password and not an expired session.
An action on several hosts asks every host (lib/hostResults.ts). forEachHost waits for all of them and throws one HostActionError whose message names each host that refused, with its reason. What is sent without a dialog β a check, a pull, a start, a stop β goes through hooks/useDockerActions, which shows that message as a toast; a remove or a prune lets it throw into its dialog, which stays open.
A bulk action reports in one toast (hooks/useBatch). A header's check and pull, start and stop of the picked rows, every pull of more than one image (usePull) and the reload of every client send their requests through the β¦Async actions of useDockerActions, which report nothing, and hand them to useBatch. Its toast shows a bar while requests are out and turns into the outcome once all are through: "Pulled 12 images", or "Could not pull 1 of 12 images" with up to three reasons, which stays until it is dismissed. The bar stays at least a second, so an action that is through at once still shows that it ran. What it says is runningToast and outcomeToast (lib/batchToast.ts), pure functions. The count is of requests β one per image, one per container β not of hosts; a refusal on several hosts names them in its reason. Dismissed while it runs, the toast is not raised again until the outcome. A row's own action keeps its own toast.
Queries (queries/)¶
| Module | Holds | Kept current by |
|---|---|---|
clients.ts |
The registered clients and their status. Update and delete are optimistic and roll back when the server refuses. | CLIENTS_UPDATE |
docker.ts |
One entry per client: its DockerState. useDockerStates() gives the lists across the fleet all of them, keyed by client id. Also the Docker actions. |
DOCKER_STATE_UPDATE |
projects.ts |
The managed projects with their counts. Create, update and delete read the list again before they resolve. | PROJECTS_UPDATE |
activity.ts |
The activity list as the server reads it for the session's user. Marking seen and deleting all are optimistic. | ACTIVITY_UPDATE, ACTIVITY_APPENDED, ACTIVITY_SEEN |
scheduler.ts |
The status of the schedulers the server runs. | SCHEDULER_STATUS_UPDATE |
autoUpdate.ts |
The configured auto-update label. | AUTO_UPDATE_LABEL_UPDATE |
webhooks.ts, users.ts, tokens.ts |
Their lists. Nothing broadcasts a change, so they go stale by age and are read again after a change made here. | β |
The entries a message keeps current never go stale by age (staleTime: Infinity). In particular a host's Docker state is requested at most once, for a page that is open before the socket has delivered it; a CLIENTS_UPDATE causes no request. The settings form is deliberately not a cache entry: a reconnect would read it again and overwrite what is typed and not yet saved.
How an entry changes is a pure function in lib/cacheUpdates.ts, tested without a socket or a component: carryUpdateChecks (a new state keeps the update checks the cached one knows β the same tag on the same platform, current after a pull, dropped when the digest moved elsewhere), applyImageCheck, appendActivity, markActivitySeen and applySchedulerUpdate.
A push and a request race on every page load, and the rules say which one stays: the server sends the client list, every host's Docker state and the activity on connect, while the page asks for the same over HTTP.
- A host's Docker state carries
updatedAt.newerState(lib/cacheUpdates.ts) keeps the later one, whether the socket or the request delivered it. - A list the socket delivers whole (clients, activity) has no such stamp.
readUnlessPushed(lib/queryClient.ts) keeps what the socket wrote while the request was under way: that is at least as new as what the request read.
Checks and pulls under way are read off the pending mutations. useCheckingImages() and useUpdatingImages() (queries/docker.ts) build the maps the Update columns animate from, so a key cannot be left behind by a path that forgot to clear it.
The cache is cleared on logout: what it holds was read for the user who is leaving.
Real-time Updates (WebSocket)¶
The WebSocketProvider (src/features/app/context/WebSocketProvider.tsx) maintains a persistent WebSocket connection to the backend (ws://.../ws/dashboard), authenticated by the session cookie the browser sends with the handshake.
The messages are a contract in @dim/shared. DashboardMessageSchema (shared/src/dashboardMessages.ts) lists every message the server sends a dashboard as one discriminated union; the backend's senders take that type, and the provider parses each message against the schema (features/app/lib/dashboardMessages.ts). What does not match is dropped and reported once per type. The dispatch is a switch that ends in assertNever, so a message type without a case fails typecheck.
| Message | Written to |
|---|---|
CLIENTS_UPDATE |
The client list, replaced |
DOCKER_STATE_UPDATE |
That client's Docker state, through carryUpdateChecks |
DOCKER_ACTION_RESULT |
Nothing: the request that asked for the action gets the same result as its own answer |
SCHEDULER_STATUS_UPDATE |
The scheduler status, one scheduler at a time |
AUTO_UPDATE_LABEL_UPDATE |
The auto-update label |
PROJECTS_UPDATE |
The project list, replaced |
ACTIVITY_UPDATE |
The activity list, replaced |
ACTIVITY_APPENDED |
The activity list β new events merged by id, newest first |
ACTIVITY_SEEN |
The activity list β the ids marked seen, also from another tab |
On connect the server sends CLIENTS_UPDATE, every stored Docker state and the activity list by itself, so the first screen fills without waiting for a REST call.
A lost connection is said. The dashboard does not poll, so without the socket the page shows a snapshot. Five seconds after the socket is gone (isLost in WebSocketContext) the shell shows the library's ConnectionBanner and wraps the page in StatusDotProvider live={false}, which stops every status dot from pulsing. The delay keeps a server restart from flashing the banner. After a reconnect, everything the server does not send again by itself (isPushedOnConnect in lib/queryKeys.ts) is invalidated: what is on screen is read again, the rest when it is next shown.
π§© Feature Details¶
Overview (features/dashboard)¶
The page at /: four StatCards, each a count and the way to the list behind it β clients online, containers with an update available, containers not running, and unseen errors and warnings.
A card does not show a number it cannot stand behind. With no client connected, "Containers not running" shows β and "No client is online" instead of a zero that would say everything runs (notRunningReading). "Updates available" still counts the last known state of offline hosts, and says below its number how many of the updates sit there (updatesReading, updatesOnOfflineHosts).
Cards and badges count with the same functions. lib/dashboard.ts holds clientCount, updatesAvailable and notRunning; unseenProblems sits in features/activity/lib/unseenTone.ts, and the tone of the sidebar's dot is derived from its counts. AppLayout calls the same functions for the badges on Clients, Containers and Activity, so a card and its badge cannot disagree. The container counts run on the groups of buildContainerGroups, which is what the container list shows β a stopped container on an offline host is not counted, as its group reads unknown there. Everything is read from the cache the socket writes, so a card changes when a host does.
The fleet views are pure functions¶
What is shown across hosts is computed outside React: buildContainerGroups (features/containers/lib), buildImageTree (features/images/lib) and hostStates / projectAssignment / buildProjectMembers (features/projects/lib), each with a test next to it. The hooks of the same name (useContainersData, useImagesData, useProjectMembers) read the cache and call them in useMemo. Three rules live in one place each: checkStatus reads a registry check, normalizeImageRef adds :latest to a reference without a tag (a container's configImage is compared with a host's repoTags through it), and isCheckableRef says whether there is a tag to ask a registry about.
Measured on a synthetic fleet, each of them takes under 4 ms for 50 hosts with 40 containers each, and at most 15 ms for 200 hosts with 60 β which is why the aggregation stays in the browser and has no endpoint of its own.
ManagedClients (features/clients)¶
The container component for the client management view. Coordinates between the client list, the editor and the add-client wizard.
- Functionality:
- Displays the list of clients (
ClientList). The table shows the status and the last auto-update; the ID, the version and the capabilities are in the list view only. A connected host shows an "Online"Badge; one that is not says in plain text when it was last seen, and an inbound client whose agent has not registered yet says Awaiting registration (awaitsRegistrationinfeatures/clients/lib/registration.ts). "Status" sorts by when a host was last heard from (clientStatusOrderinfeatures/clients/lib/clientStatus.ts): ascending, a host that never connected comes first, then the ones gone longest, the connected ones last. - Opens the client editor (
ClientEditor) for renaming a client, for inbound clients editing or switching off the address its connections must come from, and for outbound clients the address the server dials.Escapeleaves the editor and discards, as the Cancel button beside it does; while anything has been changed the footer says so, which is the safety net for both. The field is validated withIpv4OrCidrSchemafrom@dim/shared, the same rule the server applies; server errors are shown in the form. An allowed address that would not letinboundLastIpβ the address of the agent's last successful connect β back in is called out beneath the field, using the sameisIpAllowedthe server decides with. It does not block saving: the value is well-formed and the agent may have moved on purpose, so this is a consequence worth seeing, not a reason to refuse. The editor also carries this host's auto-update schedule: a box for "give this host its own", and the expression under it. The box off means the default from the settings applies; on with an empty expression means the host auto-updates only what belongs to a project. Saving reaches the agent at once β it runs that schedule itself, from the policy the server sends it. - Opens the
AddClientWizardβ one flow for both connection modes. There is no list of registration tokens: a token is a property of the inbound client it was issued for. - Deletes clients after a confirmation that says what goes (the server-side record and cached Docker state) and what stays (everything on the host; the agent keeps running but is refused).
- Displays the list of clients (
LoadingIndicator and StatusDot (@stefgo/react-ui-components)¶
Both come from the UI library; the local copies that preceded them are gone.
LoadingIndicator is "something is on its way", for a view with nothing to show yet. role="status" announces the label when it appears; the spinner is decorative.
StatusDot takes a tone, the role of a state, never the state itself β so each caller maps its own words onto one:
- A client is live or not.
onlineTone(online)infeatures/clientsgivessuccessorneutral; it takes a boolean rather than a client's status field, because two of the call sites have only the boolean. - A container has more states.
stateDot(state)infeatures/containers/containerState.tsgives the props of the dot:runningis the same glowing dot a connected client gets,restartingpulses, andunknownβ the container of an offline host β is a hollow ring, which is not "stopped". Every list that draws a container reads it from there.
The dot is used without a label, so it is decorative: every place that shows it also names the state in text beside it.
Columns of a list¶
A DataMultiView with a table and a list view describes its columns once, as columns (DataColumnDef), instead of as tableDef and listColumns: the heading is also the list label, and one render(item, view) serves both views. Where the two views differ β the list leads with a bolder name, the table has a column the list folds into another β the cell reads view, or the column is switched off for one view with table: false or list: false.
listGroups(), actionsColumn() and their tree variants come from @stefgo/react-ui-components, as do PAGE_SIZE and listPagination(): the layout every list shares lives in the library, where the sibling apps read it too. listGroups() gives the two blocks every list row has, the content and the actions at the right edge, and actionsColumn(render) the actions as the last column of the table and the second block of the list. A list whose actions differ between the views (ManagedProjects) or are missing for some rows (ClientNetworkList) builds that column itself and names the block with ACTIONS_GROUP.
A view with a table only keeps tableDef. columns always produces a list view as well; on a view that has none, that would add a view switch and force the empty list on a narrow screen. sort.colIndex counts the table's columns, so a column with table: false has no index.
A tree is described by columns too, because a phone cannot show its table. Below 768 px DataMultiView shows the list view, and for a tree that list keeps the children under their row, indented, behind the same expand button. treeListGroups() and TREE_LIST lay such a row out on one line: what it says on the left, cut off rather than wrapped, and its actions on the right, so every action of a row is reachable without scrolling sideways. The list keeps what a row is acted on by β in the container tree the state, the name, the update status after it and the image below β and leaves the counting columns to the table (list: false). treeActionsColumn(render) is the actions column for it. The container and the image tree offer the list as a second view on a wide screen as well; the two trees of a project's tabs spread TREE_ONLY, which hides the switch β there the hierarchy is the point. The list view does not sort, so ManagedContainers hands its groups over by name.
An empty list and a search without a hit are two messages. A list with nothing in it shows the library's EmptyState as its emptyMessage; a search that found nothing shows noResultsMessage ("No clients match β¦"), which must not read like a list nobody has added to yet. The view can only tell the two apart when it does the filtering, so a list passes all of its rows as data and its match function as searchFilter; rows filtered in front of the view look like an empty list to it.
Two kinds of list still filter in front, and word their emptyMessage themselves by whether there are rows before the search:
- A tree whose search keeps a row for a match below it (
ImageRepositoryList,ManagedContainers,ProjectClients,ProjectImages). The view askssearchFilterabout the top rows only and shows a row's children whole, so it cannot keep a host for one matching container and drop the others. ActivityView, whose rows are formed after filtering: repeats are collapsed once the level, the seen state and the search have taken their part.
A filter that is not the search β a container's state, the activity's level β is applied in front as well.
A list's buttons have one place. The card's header holds extraActions: creating something, a project's pull, a host's prune, and buttons over the rows. A button over the rows acts on the picked rows once something is picked, and without a pick on what the list shows: every row while nothing narrows the list, the hits alone while a search or a filter does. The check says "Check" whatever it reaches (CheckLabel), a prune "Prune". With a pick, the tooltip says how many of the picked rows a button reaches, and every button asks first: the dialog names how many rows the action reaches and, where that is fewer than are picked, how many of the picked ones (describeStartSelection, describeStopSelection, describePull, describePruneSelection). A pick left over from earlier is so never acted on unseen. The check in a header asks first with or without a pick, as a prune does: every list's check goes through useConfirmCheck (features/images/hooks), whose dialog, describeCheck, names how many rows it reaches in the list's own word and which of them β every row, the ones the search or a filter leaves, or the picked ones (listScope). A row's own check goes out at once. What only makes sense for picked rows β a start, a stop β stands next to them, disabled until something is picked, so the first pick moves no button. The pick is counted behind the title. A pull is a button over the rows like any other: in a project's tab it reaches the picked containers, the ones the list shows, or the whole project, by the same rule. The component that keeps the search filters the rows for its header buttons with the same match function it hands the view as searchFilter; where a list keeps its own search (ClientList, ImageList, ImageContainerList), extraActions is a function that receives the rows it shows and the scope.
/ puts the cursor into the search of the list on screen (hooks/useSearchHotkey, mounted once in AppLayout). The search field of every DataMultiView is a searchbox, and the hook focuses the first visible one β no ref through each list. isSearchHotkey (lib/searchHotkey.ts) is the rule: a bare slash, not one typed into a field and not one with a modifier. While a dialog is open the key is left to it.
Forms (hooks/useEntityForm, hooks/useUnsavedChangesGuard)¶
Every editor keeps the same three things: a draft, what it was when it was opened or last saved, and how the save went. useEntityForm holds them, and each editor used to build them by hand, with JSON.stringify for "has it changed".
- Checked before it is sent. The draft is turned into the request (
toInput) and parsed with the schema the backend parses that request with --WebhookInputSchema,UpdateClientSchema,CreateUserSchema,CreateProjectSchema. An issue is shown at its field (fieldOfmaps a request key to a draft field); what belongs to no field isformError.rulesadds what only the form knows, such as "a ticked restriction needs an address". - Errors appear with the first change. A form that was just opened has a disabled Save because there is nothing to save, not because a field is wrong.
- The rules are pure.
lib/entityForm.tsand thelib/*Form.tsmodule of a feature (clientForm,userForm,webhookForm) hold them apart from React, where the tests reach them.
useUnsavedChangesGuard(isDirty, editor) is the one place unsaved work is asked about. Every way out of a page goes through the router's blocker -- the close button, Cancel, Escape, an entry in the sidebar, the browser's back button -- so they all ask the same question (describeDiscardChanges in components/confirmations.ts). The editors used to ask only for their own close button and Escape; a click in the sidebar dropped the edits without a word. A reload or a closed tab is not a navigation the router sees, and gets the browser's own prompt (beforeunload).
It returns close (leave for the parent in the route tree, asked about while dirty) and leave (the same without the question, for the navigation that follows a save; it takes another target when the save leads elsewhere, as a new project leads to its page).
| Surface | Form | Guard |
|---|---|---|
WebhookEditor |
useEntityForm, webhookForm.ts |
yes |
ProjectEditor |
useEntityForm; name clash and query conflicts are added to its verdict |
yes |
ClientEditor + ClientIdentityCard |
useEntityForm held by the editor, clientForm.ts |
yes |
UserDialog |
useEntityForm, userForm.ts |
no: a modal, not a route |
Settings |
its own draft per section, checked by sectionError |
yes, while any section is unsaved |
Dialogs¶
Modal from @stefgo/react-ui-components is what a dialog is built from. The three hand-built overlays that preceded it (fixed inset-0 bg-black/80 β¦) had no focus trap, no Escape, no scroll lock and no focus return.
UserDialogturnscloseOnOverlayClickoff: it holds unsaved input, and a stray click beside it should not discard the work.RegistrationTokenModalturnscloseOnEscapeoff as well and hides the close button. The token is in the clear exactly once, so dismissing the dialog is not a way out but the loss of what the flow was for; the button below it is the only way on.
Editors that live in the workspace rather than in a dialog bring their own Escape on a window listener β see AddClientWizard and ClientEditor.
Confirmations¶
Every question before an action, and every notice after a failed one, goes through useConfirm() from the library. ConfirmProvider sits next to ToastProvider in App.tsx and renders the one dialog that answers; no component keeps a pending request, a busy flag or a ConfirmDialog of its own, and no component calls window.alert or window.confirm.
A pull & recreate is the one exception, and goes through usePull() (features/app/context/PullContext.ts): its dialog can offer a forced pull, which a plain confirmation cannot. PullProvider sits inside ConfirmProvider and renders it. Every button that pulls β a row's, a header's over the picked or shown rows, a project's, a page's β only describes the pull as a PullRequest (features/images/confirmations.ts): the targets, the forced targets where a forced pull is offered (a project's), the part of the list it reaches (scope, absent on a row) and whether it recreates anything. describePull writes the dialog from it, so the dialog names what the button sends: the references, the hosts, the containers it is limited to, and with a scope whether that is every row, the ones a search or a filter leaves, or the picked ones. A row's own pull of one image goes out through updateImage and reports a refusal itself; everything else goes into one toast (useBatch). The progress shows in the Update column, so the dialog closes at once.
confirm(options)resolvestrueorfalse. An action that is quick to hand off β a pull, a discard β runs after theawait.- An action whose outcome is worth waiting for β a delete, a remove, a prune β goes in
onConfirm. The dialog stays open and busy until it settles; a rejection keeps it open with the error inside it, next to the button that retries. That is whyClientOverview.sendActionthrows rather than reporting the failure itself. alert(describeFailure(title, error))fromutils.tsreports a failure of an action that was not asked about first, such as the cleanups in Settings.
The texts live in a confirmations.ts per feature (activity, clients, containers, images, projects, tokens, users, webhooks), one describeX(...) per action, returning the complete options including variant. A component decides that it asks, never what the question says or whether it is danger. The reasoning behind a wording β what the agent really does, what stays on the host β is kept as a comment on its function.
AddClientWizard (features/clients/components/add-client)¶
One flow for both connection modes, built on Wizard from @stefgo/react-ui-components. Step 1 is the decision about which side opens the connection; step 2 is the branch that follows from it. As two separate entry points this was a decision the operator had to have made before reaching a form.
It lives in the workspace rather than in a modal, because the two branches end in different things: a token to carry to another machine, or a connection attempt that may fail with a reason worth reading.
- Inbound branch: display name (required β until the agent reports its hostname it is all the client is known by) and an optional allowed address; without one, the address the agent registers from becomes it. Finishing creates the client at once (
POST /api/v1/clients/inbound), which then waits for its agent, and shows its registration token once in aRegistrationTokenModal. Closing that dialog opens the new client's page, replacing the wizard in the history. - Outbound branch: hostname, target address and the agent's setup PIN (or its
DIM_REGISTRATION_SECRET); finishing dials the agent straight away, and a refusal is shown on the step with the agent's own reason. - The wizard renders only the current step, so the form state lives above it in
useAddClientFormβ a step holding its inputs in its ownuseStatewould lose them on Back. - Both fields that the server validates are checked in the form with the same functions the endpoints use (
Ipv4OrCidrSchema,normaliseTargetAddressfrom@dim/shared). Escapeleaves the wizard. The listener sits onwindow, one level further out than menus and dialogs that listen ondocumentand stop the event there, so an open select closes itself without taking the wizard with it. It is off while the token is on screen: that dialog is acknowledged by button, because the token is shown exactly once.
ClientOverview (features/clients)¶
The detail view for a single client, shown when navigating to /clients/:clientId. Uses Card and ActionMenu from @stefgo/react-ui-components and renders four tabs backed by the client's Docker state (useDockerState in queries/docker.ts):
ClientContainerListβ containers, with an Up-to-date column, a Check button in the header that checks the containers of the host the list shows, Check for Update and Pull & Recreate as buttons in the row and start/stop/restart/remove in its menu. The update status and both update actions come from the container's instance row (useContainersData,useContainerActions), so they behave exactly as on the container instance page.ClientImageListβ images, with an Up-to-date column, a Check button in the header that checks every image a container of the host runs, of those the list shows, Check for updates and Pull & Recreate as buttons in the row and pull/remove in its menu. Status and actions read the image as the page a row opens does (updateStatusOfinfeatures/images/lib/updateStatus.ts). A row opens/clients/:clientId/images/:imageId. Prune in the header acts on what the list shows, as the check does: without a search it sends oneimage:prune, which removes every image no container on this host uses, tagged or not (docker image prune -a); with one, it removes only the unused images the search leaves, each with oneimage:removeper tag (by id when it has none). It asks first, names how many images go and whether that is all of them or the hits alone.ClientVolumeListβ volumes, with remove.ClientNetworkListβ networks, with remove.
Below the containers and the images tab, the host's activity narrowed to that tab: clientContainersActivityFilter takes this host's events about a container, clientImagesActivityFilter its events about an image alone β a pull, a check, a removal. Volumes and networks have no activity of their own.
An inbound client that waits for its first agent shows no cards and no tabs either. Its notice (registrationNotice in features/clients/lib/registration.ts) says whether the token issued for it is still valid, and carries a button for a new one; the header shows Awaiting registration in place of Offline and, in its details, until when the token is valid. A new token goes out without asking, because nothing is lost by it. For a registered inbound client, the action menu's Re-register Agent issues a token too, after asking (describeReregister): the agent that registers with it takes the client over β id, settings and history stay β and the one connected until then is cut off at that moment. Both show the token once in a RegistrationTokenModal; while it is on screen, useEscapeToLeave is off.
An offline client shows no cards and no tabs: its last Docker state would read as current, and its actions would go to a host that cannot answer. A notice below the header says so instead of leaving the page empty β since when the client is gone, how old the state it left behind is, and that actions are possible again once it is connected. The text is offlineNotice (features/clients/lib/offlineNotice.ts); a client that never connected or never reported a state gets a sentence of its own rather than a date. The header's details still name both times.
Every tab hands its actions to ClientOverview.handleAction. Remove actions (container, image, volume, network) stop there and ask through useConfirm() with describeRemove, naming the entry and the consequence: a container is removed with force, even while running; image, volume and network are removed without force, so Docker refuses them while in use. All other actions are sent at once.
ManagedContainers (features/containers)¶
Aggregates containers from every connected client into a tree (client β containers). Supports search, pagination, a state-based status dot, per-row container actions, and a Check button in the header that runs image update checks for every distinct image the list shows, with its filters and search applied.
Two filters sit next to the search: state (running, not running, host offline) and update (has update, up to date, not checked). Both live in the URL (?state=not-running&update=update; state.containers / update.containers in a project's tab, after its search.containers), named once in lib/paths.ts (CONTAINER_FILTER_PARAMS, containersFiltered). filterContainers (features/containers/lib/filterContainers.ts) applies them together with the search, which reads a container's name and image and the names of its hosts.
The filters work on host rows, not on groups. A group keeps the hosts that match and goes when none does; its own row still shows the state and status of the whole group. That is what makes the overview's cards exact: "Updates available" and "Containers not running" count with matchesUpdate and matchesState, the predicates of the filter, and open the list with that filter set β the number on the card is the number of host rows below. While a filter or a search is set the groups are open. A stopped container of an offline host is host offline, not not running, as on the card. The Up-to-date column sorts by how much attention a status asks for. Remove asks first; on a container row it removes every instance of that name, and the dialog says on how many clients.
Several rows can be acted on at once. Every row carries a checkbox. The header's check acts on what is picked once something is and on what the list shows before; so does Pull & Recreate next to it -- without a pick it pulls for every container the list shows that is behind; Start and Stop act on what is picked only -- off until something is picked. In a project's tab the pull is the project's (see below), over the same rows. No button carries a number in its label, which would move its neighbours with every pick; the tooltip says how many containers it reaches, and the title says how many containers are picked ("Containers (3 selected)"). "Select all" is the checkbox in the header of that column -- the list view, and with it a narrow screen, has no header row and so none -- and picks what the filters and the search leave, on every page β so with the update filter set, every hit is updated with two clicks. The rules are features/containers/lib/selection.ts: the selection is kept as host rows, a group is ticked exactly when every host row shown under it is (shownSelection, changeSelection), and a row the list no longer shows leaves the selection. planSelection turns it into what each action sends β one check per image reference, one pull per reference limited to the picked containers on connected hosts that are behind, and start and stop by the rule of a single row β and each button is off where it reaches none. Every button asks once for all of them before it acts on the selection: the pull through usePull, the check through describeCheck, start and stop through describeStartSelection and describeStopSelection; each sets what it reaches against what is picked ("2 of the 5 selected containers are reached"). A pull, a start and a stop end the selection, a check leaves it.
A click on a container row opens /containers/:containerId; a client row opens the page of that instance, /containers/instances/:clientId/:containerName. The id is the group key of buildContainerGroups (name||configImage), built and taken apart by containerGroupId / parseContainerGroupId in lib/paths.ts and nowhere else. The actions of a row live in useContainerActions, which the list and the page share, so both ask the same questions.
ContainerOverview (features/containers)¶
The detail view for one container across the fleet. An EntityHeader names it, shows its aggregate state and update status as badges, and keeps the configured image, the running count, the auto-update reading and the last registry check behind its details toggle; its menu starts, stops or removes every instance at once. Last Checked and Check Result are what the update badge cannot say: the badge reads an error as "unchecked" and falls silent, so the two rows carry the newest timestamp of the hosts' answers and, where one failed, the registry's reason (Registry rate limit reached (429)), with a count where only some hosts failed. summarizeChecks (features/images/lib/checkSummary.ts) works these out; ImageOverview shows the same two rows from the same function. Below it a table lists the instances, one per client, with their state, image, auto-update reading and per-instance actions. Check and Pull & Recreate sit in the table's header and act on every instance; the buttons of a row act on that instance alone.
A pull recreates the row's containers and no others. useContainerActions.pullAndRecreate sends containerIds per host with image:update; without them the agent recreates every container on the image, so an instance row used to take along the other containers of the same image on its host. The container list's rows share the hook and are limited the same way.
Below the table the container's activity on every host: ActivityView with the filter from features/containers/activityFilter.ts, its own search parameter (search.activity), opening on the unseen entries like the activity page. An event belongs to the page when its subject.containerName is the container's name -- the id changes with every recreate -- or, without a name, its containerId is one of the current instances. An event about the image alone, such as a pull, is not listed.
An offline host's containers are not read as current. The server keeps the last snapshot a host reported, and a host that went away -- or an agent that stopped its own container -- leaves that snapshot saying running. So an instance on a disconnected client shows a hollow dot and "Unknown (client offline)", the group's state is read from the instances on connected hosts only (unknown when there are none), and every action skips the offline instances: start, stop, remove and pull are disabled where nothing is left to reach. The container list follows the same reading.
A container's uptime keeps counting. Docker's status text ("Up 4 hours") would be frozen when the agent took its state, and the agent sends a new state only when something happens on the host, so the agent does not send it at all. Every list shows ContainerStatus (features/containers/components), which derives the text from state, health, startedAt, finishedAt and exitCode by the rules of docker ps (containerStatus in containerState.ts, humanDuration in utils.ts) and re-renders on the tick of hooks/useNow -- one interval of 30 s for the whole page. Where the timestamps are missing, from an older agent or a stored state, the text goes without its duration ("Up", "Exited (0)"). A search over the status matches the text as shown.
When something happened is written as the distance from now. The activity list's time column and "last seen" in the client list and on a client's header show RelativeTime (components/RelativeTime.tsx): "just now", "5 min ago", "2 h ago", "3 d ago", with the date itself in the tooltip and in the element's dateTime. The text is formatRelative in utils.ts; past thirty days, and for a date that lies ahead, it writes the date instead. It moves on the same tick as the uptime. A sentence that states a date -- the offline notice, the details of a header, the repeats of an activity row -- keeps the date.
A row's menu offers Start, Stop, Restart and Remove, built by containerMenuEntries for the list across all hosts and for both menus of the container page. Which instances an entry reaches is startTargets, stopTargets and restartTargets in containerState.ts: only connected hosts, and only a running container restarts. An entry is enabled exactly when its list is not empty, and the request goes to that list.
Escape, like a removed container, leads back to /containers, the page's parent in the route tree -- also when the page was opened from a project's tab. An id that matches no container says so on the page instead of redirecting. A click on an instance row opens that instance's page.
ContainerInstanceOverview (features/containers)¶
One container on one client, at /containers/instances/:clientId/:containerName. The instance is addressed by its name, which is unique per host, not by the Docker id, so the URL survives a recreate. The route sits below the containers in the tree, so the Containers entry of the navigation stays active whichever list opened it. Its EntityHeader shows the instance's state and update badges in the row; Start, Stop, Check, Pull & Recreate and the menu (remove) act on this instance alone. Start and stop sit in the header rather than behind a menu, so each asks for confirmation first (describeStartContainer / describeStopContainer in confirmations.ts); the container lists keep them in their menus without a dialog. Its details are three detailGroups from instanceDetails.tsx in one card, since all three describe this one instance, and one Show more opens the rest of each: Client -- the client (linked), its hostname and its address (the last IP of an inbound agent, the target address of an outbound one); Container -- the status as docker ps writes it, the container id, the current image, and on request the auto-update reading, creation date and ports; Image -- the image the container runs: the one behind its imageId, not the one its tag points to now, so a pull without a recreate shows as Superseded by a newer pull. It carries the configured image (linked to the image page), image id, platform, size, creation date, tags, digests, the OCI labels the image sets itself (title, version, revision, build, source), the host's last registry check and, with an update pending, what the registry says about the new image (remoteImageDetails in features/images/lib, shared with ImageOverview; both read the labels through ociLabelDetails). The source is always the last row and spans every column; a source the two share is named once. Below that the instance's activity: containerInstanceActivityFilter reads events the same way as the container page -- by name, falling back to the current id -- and takes only those whose clientId is the instance's host. Back leads to the container's page across all hosts.
ManagedProjects & ProjectOverview (features/projects)¶
A project is a group of containers across the whole fleet, defined by a query over clients, containers and images (see Projects in the API reference). A container belongs to one project at most.
ManagedProjects: every project with its auto-update setting, its schedule, how many containers it currently has, and an Up-to-date column drawing the same icon the image lists use, from the worst status among the images the project runs. The row acts on it as well: Check asks the registry about every image of the project, Pull & Recreate asks about the project's pull (see below); the header's Check does the same across every project the list shows, asking once per reference rather than once per project. Edit and Delete sit in the row menu; the delete dialog says that only the DIM entry goes and no container is touched. There is no Clients column β the project page answers that.ProjectEditor: one page for/projects/newand/projects/:projectId/edit, laid out like the add-client flow (Escapeleaves, back goes to the parent in the route tree). Name, query, auto-update and schedule, and below them the result table, recomputed on every keystroke from the Docker states in the cache: every matching container with its client, image, the numbers of the criteria that match it, and the project it already belongs to, if any. Saving is blocked while there are such conflicts, while a criterion has no value and while the name is taken; the server checks the same again.QueryBuilder: one row per criterion that reads as a sentence β AND/OR toggle, category, attribute, operator ("is", "is not", "matches pattern", "does not match"), value with suggestions from the fleet. Typing*or?switches to pattern matching. Rows can be moved, duplicated and removed; each row shows how many containers it matches on its own, and the query is repeated as the expression it is evaluated as, top to bottom, bracketed where AND and OR mix.- Conflicts: a container that matches several projects is listed in each of them. The Auto-Update column marks it with an error icon and links to every project involved ("Conflict", or "Label" where its label carries it to the host schedule); a grouped row that reads "Mixed" carries the icon when any instance is in conflict. The project list shows the number of such containers next to the name, and the project page explains it in its header, where it stays visible while the details are closed.
ProjectOverview: anEntityHeaderwith the query in its readable form always in view and the two settings in its collapsible details, the members below in three tabs. "Use the default schedule" writesnull, which means inherit β auto-update is switched off through its own control, never through an empty schedule.- Containers is the fleet-wide
ManagedContainerslist narrowed to this project, so a row means the same thing and offers the same actions as in the sidebar view. ProjectImagesgroups the same members by the image they were built from: image β the containers that run it, with the update status and Check / Pull & Recreate per row.ProjectClientsgroups them by the host they run on: host β its containers, with the host's online dot, and the same update column and actions β a check from a host row covers every distinct reference its containers were configured with.- Below the tabs, in all three, the project's activity:
ActivityViewwith the filter fromactivityFilter.ts, its own search parameter (search.activity), opening on the unseen entries like the activity page. An event belongs to the project when itssubject.projectIds(entered by the server when it stored the event), thesubject.projectIdof an auto-update run or thedata.projectIdsof a conflict name it. Events stored before the server enteredprojectIdsfall back to the current membership: same host, and one of the project's containers or an image one of them runs.
- Containers is the fleet-wide
- Project pull (
ProjectPullButton,projectPullRequestinpullPlan.ts): the list's row action and the Pull & Recreate in the header of each of the three tabs ask throughusePullwith a forced pull beside the plain one. The dialog offers two options, each with the number of containers it recreates: only with an update pulls a reference on the hosts whose own copy a check found behind; all containers (force) pulls every tagged reference on every host and sendsparams.force, so the agent recreates containers already on the new image as well. Both pass the project's container ids, so other containers on the same image are left alone. The row reaches the whole project; a tab's button what the tab reaches: the picked containers (the containers tab), else the ones its search or filters leave, else the whole project (ProjectPullReach, which narrowsplanPullbycontainerKey). The button is enabled while any of those containers has a reference with a tag. useProjectMembers:useHostStates,useProjectAssignment(container β project, viaresolveAssignmentfrom@dim/shared, the function the server and the agents use too) anduseAllProjectMembers, each a call of the function of the same name inlib/projectMembers.ts. Everything is derived from the Docker states the cache already holds, so a container that starts or stops matching moves without anything being fetched.ProjectMembers.targetscarries one entry per image reference β the digests a check is keyed by, the hosts a pull has to reach, and how far behind it is;imageCountand the project's update status are derived from it, and it is what the list's row actions act on.
ManagedImages & ImageOverview (features/images)¶
ManagedImages renders a three-level tree: Repository β Tag β Digest, with per-node actions (Check Update, Pull & Recreate, Remove, Prune). Update status animations are driven by useCheckingImages() and useUpdatingImages() (queries/docker.ts), scoped per digest. Filtering via the search bar traverses the full tree so matches deep in a tag/digest still surface. Every prune action (per row and in the header) asks first and names how many images go.
Several images can be acted on at once, as in the container list. Every row carries a checkbox, "Select all" is the checkbox in the header of that column, and the title says how many images are picked ("3 selected"). The rules are features/images/lib/selection.ts: the selection is kept as digest rows, a tag is ticked exactly when every digest row shown under it is and a repository when every tag is (shownSelection, changeSelection), and a row the search no longer shows leaves the selection. The header's Check, Pull & Recreate and Prune act on what is picked once something is and on what the list shows before -- the pull for every image shown that is behind. Each asks first and names how many images it reaches (describeCheck, describePull, describePruneSelection). planSelection turns the selection into what each sends. A prune of the selection removes a tag by its name only where every digest row shown under it is picked; of a tag picked in part it removes the picked images alone. No button carries a number in its label; the tooltip says how many images it reaches. A pull and a prune end the selection, a check leaves it.
ImageOverview is the dedicated detail page (/images/:imageId) with StatCards and two DataMultiView tables: one for the image's tags/digests β each row with Pull & Recreate for its host, enabled while an update is available β and one for the containers that use them. Its Prune button asks first as well. Both tables' header buttons act on the rows their search leaves.
The page is built like the client and container pages. Its header carries the details (repository, tag, digest, hosts, size, last check β and, for an image with an update, what the registry's OCI labels say about the new image: title, version, revision, build date and source, each only where the image sets it; the source last and across every column) and an action menu with Check for Update and Pull (or Pull & Recreate). Prune stays with the list below: it acts on the images listed there. Check and pull come from useImageNodeActions, which the image list's row actions use too, so a row and its page cannot disagree about what is possible. The open tab is kept in the URL. Escape leads back to the image list, the page's parent in the route tree.
A row of the image list opens ImageInstanceOverview at /images/instances/:clientId/:imageRef: one image reference (repository:tag, URL-encoded) on one client. It is addressed by reference, not by image id, so a pull that moves the tag to a newer image keeps the page on it; ImageOverview tells the list which of an image's tags the row stands for (the page's tag, or on a repository page the first tag in that repository). Like the container instance page, it sits below its list in the route tree, which keeps the Images entry of the navigation active. Its EntityHeader carries the update badge, Unused where no container was created from the reference, Check for Update and Pull (or Pull & Recreate) for this host alone -- no remove -- and three detailGroups: Client and the image details from instanceDetails.tsx (clientGroup, imageDetails), plus New Image with an update pending. Below it the containers created from the reference or running its current image, each opening its instance page, and the activity: imageInstanceActivityFilter takes the events of this host about the reference (compared with imageRefKey, latest filled in) and about those containers, by name and, without one, by id. Back leads to the reference's page across all hosts.
Escape on a detail page β client, container, image, project β is handled by hooks/useEscapeToLeave. It does nothing while the focus is in a field, so Escape in a list's search box clears nothing and leaves nothing.
ActivityView (features/activity)¶
On a phone the message is the row. Below 640 px it wraps instead of being cut off, the time column is hidden and the time stands under the message and its chips, and the cells give up their gutters, so nothing of a row lies outside the screen.
The page at /activity, and the activity list of a project or container page. With a filter it shows
only the groups with an accepted event, takes its start level from those alone and offers no
"Delete all", which would delete more than the list shows. Its entries are structured events: a kind, a level, what the
event is about and the facts of that kind.
The text is written in one place. activityText.ts in @dim/shared is the one place a
wording exists β the webhooks send the same sentence as {{event.message}}: an agent
reports container.died with an exit code and nothing else, and the sentence is composed
from that. So an agent of an older version stays useful without knowing how today's
dashboard phrases things, a wording can be changed without asking a fleet of hosts to
update, and the level filter works on level rather than on a search through prose. A
kind this build does not know still gets a row β the fallback prints the kind itself, because
dropping the line would hide an observation nobody can make again.
Rows are groups. groupActivity.ts folds the flat list by correlationId: the
summarising event (autoupdate.run, action.requested) is the head, the rest are its
expandable steps (ActivityGroupSteps, with an N steps badge). The head carries the most
severe level in the group, so a run whose last step failed does not read as an untroubled
one. Grouping is a lookup, not a guess β whoever caused the group put its id on every member
β so nothing depends on arrival order and an event delayed by an offline stretch still lands
in its group hours later. A group with no head yet (an action still running) is stood in for
by its earliest member, so no event can go missing. An action.unconfirmed (the server's
"result pending") is dropped from its group once the agent's action.completed or
action.failed has arrived (supersededIds); it no longer counts towards the group's level or
the badge, and seeing the group sees it too.
One kind of row is a guess: a lifecycle burst. A container recreated on its host with
docker compose up reports six events (killed, died, stopped, removed, created, started)
that carry no correlationId, because the agent only watched. groupActivity folds the
uncorrelated lifecycle events of one container name on one host into one row while each lies
within ten seconds of the next (LIFECYCLE_WINDOW_MS), and lifecycleTitle names it:
recreated, restarted, created and started, removed. By name and not by id, since a
recreate changes the id. The window only chains; a row holds at most one start
(splitRuns), because a restart policy starts a crashing container again within a second or
two. A crash loop is thereby one row per run, exited with code 1, which the repeats fold into
one, and the restart before it reads restarted, then exited with code 1: a run that ended
within the burst is named by how it ended. Such a row lists every event as a step, shows no kind chip, carries
the most severe level of its steps, and is marked seen as a whole. A correlated event is never
taken into a burst, and container.health is not a lifecycle event.
A container.killed -- the signal Docker reports in front of a stop -- is a lifecycle step
too, so it lands in the row of the stop it announces instead of a row of its own. Whether the
exit after it was asked for is not decided here: the server stores such an exit as info
(see Backend), and the list shows the level that was stored.
Repeats are one row. collapseRepeats (lib/collapseRepeats.ts) folds neighbouring rows
that say the same about the same thing β same sentence, kind, level, host and subject, and
the same seen state β into the newest of them, with a 4Γ badge; expanded, the row lists
when the others happened. It runs after the filters and the search, so what they hide no
longer stands between two repeats, and never across a row that says something else. A
correlated group with steps is never folded: it is told apart by what it did. "Mark as seen"
on the row covers every repeat (rowEvents).
The level filter is a minimum, and its entries say so (β₯ warning). It sits at the right end of the search bar (searchActions)
and opens on what needs a look: error while an error is unseen, else warning while a
warning is, else info β the same rule as the sidebar badge. The start is fixed once the list
is known, so marking rows seen does not move the filter. trace events β agents connecting
and disconnecting β are hidden until trace is chosen.
A second filter hides what has been seen. Next to the level filter, Show: all / Show: unseen
switches between the whole list and the rows with something unseen in them; under unseen a
row leaves the list once it is marked seen. It starts at unseen, on the activity page and
on a project or container page alike.
"Mark as seen" follows the filter. It marks the unseen events of every row the level filter and the search leave, across all pages, and nothing the reader has not been shown.
Entries are not deleted one by one. A row can be marked seen; the history goes as a whole
("Delete all") or through retention. The sidebar badge does not
count them either. An event that names a host but carries no clientName (recorded before
the server stored it) gets the name from the client list by clientId.
The chips below a row are links. activityLinks (lib/activityLinks.ts) gives each its
target: the host's page, the container and the image on that host, the project. A host that
has been deleted since leaves its chips as text, and so does a project where the event is
about several. The kind has no page and is always text.
Everything else is found through the search box, as on the other lists (useSearchQueryParam,
so the query survives a reload). It matches the sentence a row shows, its detail line, the
kind, and the host, container, image and project the event is about. A group matches when
any of its events does, so a step is found under the operation it belongs to. The sidebar
badge counts single unseen events, not groups.
UserOverview (features/users)¶
Manages user accounts. Supports creating, editing, and deleting users via a UserDialog form. Deleting asks first; the dialog states that a session the account already holds stays valid until it expires, because the API checks only the JWT. For the last remaining user a second dialog explains why it cannot be deleted instead of sending the request. UserList is a DataMultiView like every other list: search by username, a list view for narrow screens, pagination.
WebhookOverview & WebhookEditor (features/webhooks)¶
Its own entry in the sidebar, in the Administration group above Settings. WebhookList is a
DataMultiView like every other list β search over name and URL, table and list view β and
shows per webhook its target, its filters, a switch to turn it on and off, and how the last
delivery ended, with the reason of a failure. The Edit action opens the editor; Delete asks
first.
The editor is a page, not a dialog, at /webhooks/new and /webhooks/:webhookId. It
leaves the way the ClientEditor does: the close button in the card's header, Escape, or
Cancel -- and any other way out, see Forms above -- each asking first when there are unsaved edits, and going back to
the list; Save goes back after storing. The webhook is read
from GET /api/v1/webhooks; an id that is not there throws NotFoundError. The preview is
rendered with renderTemplate from @dim/shared β the code the server sends with β against
the sample event for the draft's kinds (sampleWebhookRecord, the one "Send Test" sends; its
kind is named above the preview), so the preview and the delivery cannot disagree. The sample
projects come with names of their own (sampleProjectName), so {{event.projects}} shows
something without depending on the projects that exist. "Send Test" posts the unsaved draft to
/api/v1/webhooks/test. See Webhooks for the template syntax.
Settings (pages/Settings.tsx, features/settings)¶
System settings page, one section per tab: Image Version Cache, Image Update Check, Container Auto-Update and Activity History. The tabs are the library's useTabs/TabList/TabPanel, and the open one is kept in the URL (?tab=). The sections live in features/settings/components; features/settings/sections.ts names the keys each one edits.
Leaving with unsaved edits asks first, for as long as any section has some; switching tabs is not leaving. A section's Save stays off while sectionError finds something the endpoint's schema would refuse, with the reason beside the button.
Every section saves on its own. Its Save sends only its own keys, and PUT /api/v1/settings/cleanup merges them into the stored block, so a section never writes over edits in another one. A tab with unsaved edits carries a dot. The manual maintenance runs act on the saved values, not on unsaved edits.
The page manages these settings, plus the manual maintenance actions:
| Setting | Description |
|---|---|
image_version_cache_ttl_days |
Max age of a cached image_update_checks entry. |
image_version_cache_cleanup_orphans |
Whether orphaned cache rows are removed. |
image_version_cache_cleanup_interval_hours |
Automatic cache cleanup scheduler interval. |
image_update_check_interval_seconds |
Interval for the image-update-check sweep. 0 disables. |
container_auto_update_cron |
The default auto-update schedule hosts and projects inherit. |
container_auto_update_label |
Docker label that marks a container for auto-update. |
container_auto_update_delay_label |
Docker label holding a per-container delay in days. |
notification_retention_days |
Days to keep activity events. |
notification_retention_count |
Minimum number of the newest activity events always kept. |
notification_cleanup_interval_hours |
Automatic activity cleanup interval. 0 disables. |
activity_level_overrides |
The level each event kind is recorded with, or none. One string; the Activity History tab edits it through one select per kind (features/settings/lib/eventLevels.ts), and "Default" names the level from ACTIVITY_DEFAULT_LEVELS. |
GET/PUT /api/v1/settings/cleanupβ Fetch and save settings.POST /api/v1/settings/cleanup/image-version-cacheβ Manually run the image version cache cleanup.POST /api/v1/settings/cleanup/notificationsβ Manually run the activity cleanup.GET /api/v1/settings/scheduler-statusβ Current status of all background schedulers.POST /api/v1/settings/image-update-check/runβ Manually trigger the image-update-check sweep, including registries paused by a rate limit.POST /api/v1/clients/:clientId/auto-update/runβ Ask one agent to run now.POST /api/v1/settings/container-auto-update/validate-cronβ Validate a cron expression.GET /api/v1/settings/container-auto-update/labelβ The configured auto-update label on its own, read byuseAutoUpdateLabel(queries/autoUpdate.ts) and kept in sync viaAUTO_UPDATE_LABEL_UPDATE.
Every tab with a scheduler follows one layout: its settings, then one SchedulerBox headed "Scheduler". It shows Status (Running⦠or Idle), Last Run (with "manual" when a user started it), Next Run (or "Disabled") and Result, and, below a divider, the ManualRun row with its Run Now button. The box draws no field borders: its values are to read, not to edit. It reads useSchedulerStatus (queries/scheduler.ts); the result is worded by describeRunResult (features/settings/lib/runResult.ts), in red for a failed or interrupted run and in amber for one a rate limit cut short. Image Version Cache, Image Update Check and Activity History have one; Container Auto-Update has none, because the server runs no auto-update.
The Image Update Check tab lists the registries above its scheduler box (RegistryStatusTable, fed from useSchedulerStatus("image-update-check").registries): one row per registry host with its image count, a status badge (Ok, Paused, Error), the last check, the next attempt while paused, the requests the registry says remain (only where it sends ratelimit-remaining) and the error. Docker Hub's registry-1.docker.io is shown as "Docker Hub" (registryLabel in @dim/shared). The scheduler box says whether the check runs; the table says why the images of one registry get no fresh answers.
The auto-update tab shows no schedule of the server's own, because it runs none, and no fleet
panel either. It is the settings and nothing else: the schedule hosts and projects inherit,
and the two labels. AutoUpdateFleet, which listed every client with one line per schedule,
is gone with the GET .../container-auto-update/status endpoint behind it β everything it
showed belongs to a host, and is therefore shown where that host is.
There is no way to ask an agent to run from the UI. The client row's play button is gone;
the row has a Reload button and an overflow menu with Edit and Delete, and the list's header a Reload
button that does the same for every client the list shows (reloadStep in features/clients/lib/clientReload.ts
says what a reload is for a client: a reconnect, a fresh Docker state, or nothing). POST /api/v1/clients/:clientId/auto-update/run
and the toast machinery in useAutoUpdateRunToasts are still in place, but nothing calls
markAutoUpdateRunAsked any more β a run belongs to its host, and the host runs it on its
own schedule.
What the client list does report is the "Last Auto-Update" column: the client's
autoUpdateLastRun, which the agent reports for every run of any schedule β also one that
changed nothing and therefore wrote no autoupdate.run β and which CLIENTS_UPDATE moves
without anybody polling. It used to be read off the newest autoupdate.run in the activity
list, which left the column empty for a host whose runs had nothing to do and for one whose
events the retention had removed. While a run is under way (autoUpdateRunningSince) the
column shows Running instead, and the client page an Updating badge and "Auto-Update
Running" with its start. The client page shows the agent's autoUpdateNextRun as
"Next Auto-Update".
A connected client whose agent cannot reach its Docker daemon (docker.reachable === false)
carries a Docker unreachable badge in the list's status column and in the page header. The
page keeps its lists β they are the last state that was read β and puts a notice above them
(dockerNotice in features/clients/lib/dockerNotice.ts): since when, Docker's message, how
old the lists are, and that actions will fail. In the list view, "Capabilities" lists
what the connected agent declared, as it named it (auto-update, project-query); an offline
client shows β, because capabilities belong to the build on the wire, and a connected agent
that declares none shows "None".
Next to it, "Docker State" shows when the client last reported its state: updatedAt of the
state useDockerStates reads from the cache, with seconds, so a reload that answers within
the minute still moves it. A client that never reported shows β.
There is nothing to enrol from a container list any more. A container takes part because
it carries the label or because the project it belongs to has auto-update switched on, so the
"Auto-Update" column in ManagedContainers and ClientContainerList is a statement rather
than a control: AutoUpdateSourceCell shows "Label", a link to the project, "Mixed" for a
row standing for instances that do not agree, or "β". autoUpdate.ts resolves that reading
from the container's own labels, mirroring what the server resolves for its sweep.
π¨ Styling & Theming¶
- Tech Stack: Tailwind CSS v3 with the
@stefgo/react-ui-components/tailwind-presetas the base configuration. - Dark Mode: Supported via the
classstrategy. Thedarkclass is applied to the<html>tag, controlled by the library'sThemeProvider, whichAppmounts withSTORAGE_KEYS.theme;useTheme()comes from the library as well. A colour is one class, not two:bg-cardresolves per theme because the preset redefines the custom property behind it in its.darkblock. Theβ¦-darktwins (dark:bg-card-dark) are gone with library 3.0, and the preset setsdarkModeitself. - UI Library: All generic components (Buttons, Inputs, Cards, Dashboard shell, etc.) come from
@stefgo/react-ui-components. Domain-specific components live insrc/features/. - Colours are roles, not palette values:
bg-success,text-error,text-warning,text-info,bg-error-bg. The library decides once what a role looks like in either theme, so a status dot cannot be a different green from one view to the next. Status pills are theBadgecomponent. - Custom Tailwind Extensions: the font family Inter, and nothing else. The font ships with the bundle (
@fontsource-variable/inter, imported inMain.tsx), so opening the application makes no request to another origin, and the server's CSP names none. The formerapp.text-footer(#444444) only existed to stay readable on a white panel, andshadow-glow-onlinewas a fixed green; the online dot usesshadow-glow-success, which the preset derives from the success token. - Tailwind Integration: Tailwind merges
darkModeandsafelistfrom the preset, but notcontent: acontentarray in the app's config replaces the preset's rather than extending it. The library's own glob is therefore spread back in, or every class only the library uses is missing from the output:
Working against a local checkout of the UI library¶
By default Vite, Tailwind and the type check all use the installed @stefgo/react-ui-components (pinned in server/frontend/package.json). A build must not depend on a sibling checkout that CI and the Docker build do not have.
To develop the library and the app together, set VITE_USE_LOCAL_UI=true in the shell (optionally VITE_UI_COMPONENTS_PATH, default ../../../react-ui-components relative to server/frontend):
| Tool | Installed package (default) | VITE_USE_LOCAL_UI=true |
|---|---|---|
| Vite | resolves the package from node_modules |
aliases the import to <path>/src/index.ts |
| Tailwind | preset and content glob from the package |
loads the checkout's tailwind-preset.js and scans <path>/src/**/*.{ts,tsx} |
| Type check | npm run typecheck -w server/frontend |
npm run typecheck:local-ui -w server/frontend (tsconfig.local-ui.json) |
Always switch all three together; otherwise the compiler checks one version of the library while Vite bundles another. Tailwind swaps the preset with the glob, because the preset carries the theme β on the installed preset a local build would run new components on the old theme. tsconfig.local-ui.json cannot read environment variables and uses the default path. compose.dev.yaml already sets VITE_USE_LOCAL_UI=true for the dev container.
π¦ UI Library (@stefgo/react-ui-components)¶
The app is heavily integrated with @stefgo/react-ui-components, pinned to an exact version (4.1.0). Components used:
| Component / Type | Usage |
|---|---|
Dashboard |
App shell with sidebar, user menu, theme toggle. Renders navigation; the pages come from the app's routes as children. |
DashboardPage |
Type for a navigation entry ({ id, path, nav }). |
LoginPage |
Pre-built login form UI (local & OIDC). |
Card |
Generic surface card. padding="none" for a card that holds a table. |
StatCard |
Clickable stat tile; selected marks the active one. |
Input |
Form input field. |
Button |
Button with variants (primary, secondary, danger). |
DataTable |
Table view with sorting and paging. |
DataMultiView |
Switches between table, list and tree views for data. |
DataTableDef |
Column definitions for table mode. |
DataListDef / DataListColumnDef |
Column definitions for list mode. |
DataAction |
Typed action descriptors for data row operations. |
ActionMenu |
Context ("kebab") menu for per-item actions. |
useActionMenu |
Hook for ActionMenu state; supplies the trigger's anchor. |
useTabs / TabList / TabPanel |
The tabbed detail views (client, image, project). See below. |
Modal |
The base every dialog is built from β focus trap, Escape, scroll lock, focus return. |
Wizard / WizardStep |
The step flow the AddClientWizard is built on. |
Switch |
On/off control β a project's auto-update, a host's own schedule. |
Select |
Dropdown in the query builder and the forms. |
useToast / ToastProvider |
Transient result messages raised from the shell. |
cn |
Class-name join; the app uses it where it draws a surface itself. |
ConfirmProvider / useConfirm |
Every confirmation and failure notice but a pull's. See Confirmations. |
Badge |
Status pill in one of five roles (success, warning, error, info, neutral). |
Checkbox |
Checkbox with label, indeterminate for a partial selection. |
ActionButton |
Round icon button with a tooltip β close, copy, expand, kebab. |
EntityHeader |
One-row header of every detail page β ClientOverview, ContainerOverview, ImageOverview, ProjectOverview: title, badges, actions, and details that are either always visible or open on request. Whether they are open is kept per page type in localStorage (dim.client.details, dim.container.details, dim.image.details, dim.project.details). |
FOCUS_RING / FOCUS_RING_INSET / FOCUS_RING_NONE |
The focus ring for the few surfaces the app still draws itself: an inline chip, a tab, a menu entry. Every library component brings its own. |
The data views own sorting and paging. A view receives the complete set in data and takes the page after sorting, which is what makes a column sort cover every row instead of the ten on screen. The page state lives in the view, configured through the library's listPagination(PAGE_SIZE.β¦) β 20 rows for a list that is a page of its own, 10 for one inside a tab; usePagination is only for holding it outside, and the app does not need it. Sorting, search and view mode follow the same shape: sort={{ defaultValue: [...] }}, search={{ value, onChange }}, viewMode={{ persist: { key, scope: "local" } }} β the persistence vocabulary that replaced the bare storageKey in library 4.0; scope: "local" is what storageKey did, so a chosen view mode survived the move.
The tabs are the library's. The tabbed detail pages β ClientOverview, ImageOverview, ProjectOverview β drive their StatCard headers and the panels below from one useTabs({ tabs, value, onChange }): it supplies the roles, the tab-to-panel wiring, the roving tabindex and the arrow keys, and the cards take their semantics from its tabProps rather than claiming to be toggles. TabPanel keeps the behaviour the app's own former TabPanel existed for, now under the name visited: a panel that has been opened once stays mounted, so a tab's search, sort and page survive a switch away and back. The active tab itself is a URL parameter, so a reload and a shared link land on the same tab.