Skip to content

๐ŸŽจ Frontend Documentation

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/         # Feature modules (Domain Logic)
โ”‚   โ”œโ”€โ”€ app/          # Providers (App.tsx), the route tree (routes.tsx), the shell (AppLayout.tsx)
โ”‚   โ”œโ”€โ”€ auth/         # Authentication & Context
โ”‚   โ”œโ”€โ”€ clients/      # Client management, lists, detail views, job editor
โ”‚   โ”œโ”€โ”€ history/      # Execution history views
โ”‚   โ”œโ”€โ”€ jobs/         # Global job list views
โ”‚   โ”œโ”€โ”€ repositories/ # PBS Repository management & snapshot browser
โ”‚   โ”œโ”€โ”€ settings/     # Settings sections (one tab each), their fields and manual runs
โ”‚   โ””โ”€โ”€ users/        # User management
โ”œโ”€โ”€ pages/            # Main pages (Entry points for routes)
โ”‚   โ”œโ”€โ”€ Login.tsx
โ”‚   โ””โ”€โ”€ Settings.tsx
โ”œโ”€โ”€ queries/          # Server data: one module per area, read through the query cache
โ”‚   โ”œโ”€โ”€ clients.ts        # Client list, one client, a client's tunnel configuration, inbound create and registration tokens
โ”‚   โ”œโ”€โ”€ clientDetail.ts   # A client's jobs, history and snapshots
โ”‚   โ”œโ”€โ”€ jobs.ts           # Jobs across all clients, their latest runs, start and delete
โ”‚   โ”œโ”€โ”€ repositories.ts   # Repositories, their status, their snapshots
โ”‚   โ”œโ”€โ”€ history.ts        # The history list, what is unseen, and marking it as seen
โ”‚   โ”œโ”€โ”€ webhooks.ts, scheduler.ts, fileSystem.ts, users.ts
โ”œโ”€โ”€ stores/           # Client-only state (Zustand)
โ”‚   โ””โ”€โ”€ useUIStore.ts     # Sidebar collapsed or not, persisted
โ”œโ”€โ”€ components/       # Cross-feature components (LoadingIndicator), the discard question
โ”œโ”€โ”€ hooks/            # Global Custom Hooks (job result toasts, URL search parameters, useBackPath,
โ”‚                     #   useEntityForm, useUnsavedChangesGuard, useEscapeToLeave,
โ”‚                     #   useSearchHotkey, useNow)
โ”œโ”€โ”€ lib/              # Non-React modules
โ”‚   โ”œโ”€โ”€ api.ts             # The one place a response is read: api.get(path, schema), โ€ฆ
โ”‚   โ”œโ”€โ”€ apiFetch.ts        # The session half underneath it: the cookie and the 401 โ†’ logout
โ”‚   โ”œโ”€โ”€ queryClient.ts     # The one QueryClient and its defaults
โ”‚   โ”œโ”€โ”€ queryKeys.ts       # Every cache key, hierarchical
โ”‚   โ”œโ”€โ”€ cacheUpdates.ts    # What a socket message makes of a cache entry (pure)
โ”‚   โ”œโ”€โ”€ paths.ts           # Every path, once: the patterns and their builders
โ”‚   โ”œโ”€โ”€ backPath.ts        # Where "back" is, from the chain of route matches (pure)
โ”‚   โ”œโ”€โ”€ entityForm.ts      # How a form's draft is compared and checked against its schema (pure)
โ”‚   โ”œโ”€โ”€ notFound.ts        # NotFoundError, thrown by a route whose subject is gone
โ”‚   โ””โ”€โ”€ realtimeEvents.ts  # Typed emitter for the high-frequency WS stream
โ”œโ”€โ”€ index.css         # Global CSS layers (glass-card, field-label)
โ”œโ”€โ”€ Main.tsx          # Entry point (mounts App)
โ””โ”€โ”€ utils.ts          # General utility functions

๐Ÿšฆ Routing & Navigation

One route tree says what lives where, and three things are read off it instead of being kept beside it: the paths, the sidebar, and where "back" is.

File What it holds
lib/paths.ts ROUTES โ€” every path pattern, once โ€” and paths, one builder per pattern that takes a parameter (paths.clientEdit(id)). No path literal anywhere else.
features/app/routes.tsx The tree inside the dashboard shell, as route objects. Each path is a ROUTES entry.
features/app/router.tsx createBrowserRouter: /login beside the shell, the shell around the tree.
features/app/routeElements.tsx What the tree renders: the boundaries and the thin wrappers that connect a page to the query cache.
features/app/lazyPages.ts The page components, each loaded with its route.
features/app/AppLayout.tsx The dashboard shell; the page is its <Outlet />.
features/app/RouteError.tsx The errorElement of every area.

/login stands alone; everything else lives behind ProtectedRoute inside the dashboard shell.

Path Component Description
/login Login Authentication page (local & OIDC).
/ DashboardOverview The dashboard: how the backups are doing, what needs attention, and the last run of every job. First in the sidebar, in a group of its own without a title, and where the login lands.
/clients ManagedClients Client list.
/clients/new AddClientWizard Adds a client, starting with the connection mode.
/clients/:clientId ClientOverview Detail view of a client; ?tab= names the open tab.
/clients/:clientId/edit ClientEditor Name and target address of a client.
/clients/:clientId/tunnel ClientTunnelEditor Adds, changes or removes the SSH reverse tunnel.
/clients/:clientId/jobs/new ClientJobEditor New job for this client.
/clients/:clientId/jobs/:jobId ClientJobEditor Edit a job; closes onto the client.
/clients/:clientId/restore/:repoId/:backupType/:backupTime SnapshotRestoreEditor Restores one of the client's snapshots.
/jobs ManagedJobs Global job list, with each job's last run.
/jobs/new ClientJobEditor New job, client picked in the form.
/jobs/:clientId/:jobId ClientJobEditor Same editor; closes onto /jobs.
/repositories ManagedRepositories Repository list.
/repositories/new RepositoryEditor Adds a repository.
/repositories/:repoId RepositoryOverview Detail view of a repository.
/repositories/:repoId/edit RepositoryEditor Repository settings.
/repositories/:repoId/restore/:backupType/:backupId/:backupTime SnapshotRestoreEditor Restores a snapshot, to a client picked in the form.
/history HistoryOverview Global execution history.
/users UserOverview User management.
/webhooks WebhookOverview Webhooks, with their last delivery.
/webhooks/new, /webhooks/:webhookId WebhookEditorRoute Webhook editor with live preview and a test sent by the server.
/settings Settings Cleanup settings and scheduler status, one tab per cleanup.
* NotFound โ€”

A list and everything below it share the plural (/clients/:clientId). The singular forms /client/โ€ฆ and /repository/โ€ฆ of earlier versions are gone without a redirect; a bookmark to one lands on the not-found page.

Every form is a route, not a state flag: the URL says what is on screen, a reload keeps it there, the link can be shared, and the browser's back button works. That includes the repository editor and the restore form, which used to be local state of the page that opened them.

The job editor sits under two path families for the same page โ€” under the client when it was opened from there, under /jobs when it was opened from the list across all clients. That is deliberate: the sidebar keeps marking the place the operator came from, and the tree says where each closes onto.

Where "back" is

An editor closes onto its parent in the route tree โ€” useBackPath(), which reads the chain of matches (lib/backPath.ts holds the rule as a pure function). Nesting a route is what decides it: edit sits below /clients/:clientId, so the client editor closes onto the client's page, whichever surface opened it and also after a reload. Nothing travels in location.state.

The query string is the part that is passed along. A page that opens a form one level below itself navigates with its own query (navigate({ pathname, search })), and useBackPath() hands it back on the way out โ€” that is how the client page's open tab and a list's search are still there on return, and why they survive a reload of the editor. A surface that opens a form which is not its child (the client list opening the client editor) passes nothing, since its query would mean nothing on the page the form closes onto. A page whose query is its own leaves with useBackPath({ keepSearch: false }).

The sidebar

An area of the tree that carries handle: { nav } is a sidebar entry; navEntries in routes.tsx reads them off in order. AppLayout adds what only the running application knows โ€” the counts and the dot for unseen runs, by id โ€” and marks the entry whose area the innermost match belongs to (useMatches()). No list of the routes below an entry exists: a route added under /clients is marked as Clients by being there.

The document title

The browser tab names what is open and the area it belongs to, most specific first, so a narrow tab cuts the application's name and not the subject: web01 ยท Clients ยท PBCM, Edit ยท web01 ยท Clients ยท PBCM. It is read off the handles along the open route (lib/pageTitle.ts): the area's nav.label, a subject (client, repository, job) that AppLayout resolves from the lists the shell holds anyway, and the title of a form. A subject that has no name yet falls back to the route's title, or is left out. A new route gets its title in the tree โ€” no page sets document.title.

The webhook editor is called Webhook, not by the webhook's name: the shell does not read the webhook list, and does not start to for a title.

The breadcrumb

A page below a list shows the way back as its heading: Clients โ€บ web01 โ€บ Edit. breadcrumb in lib/breadcrumb.ts reads the same handles as the title, outermost first, and gives each the address of its route. It names a page with ownName from lib/pageTitle.ts, so trail and title cannot disagree. Every link but the last leads somewhere; a page with nothing above it (a list, the dashboard) has no trail.

AppLayout hands the trail to the pages through BreadcrumbContext. The client and repository pages and every editor show it as the title of their first card or header (features/app/HeaderBreadcrumb.tsx), wrapped around the heading the page had before. Below the sm breakpoint the page keeps that heading, behind a โ€น that leads to the link above it (parentCrumb). current replaces the trail's last word where the heading says more: the webhook editor is Edit nightly-report, not Webhook. The trail has the size of a card title; an EntityHeader gets it through ENTITY_HEADER (components/entityHeader.ts).

The links are router links, so leaving a changed editor through one asks like every other way out. A new page below a list wraps its heading in HeaderBreadcrumb; the trail itself comes from the route's handle.

Keys

  • / 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.
  • Escape on a page that is no editor leads back (hooks/useEscapeToLeave): the client page, the repository page, the restore form. It does nothing while the focus is in a field, so Escape in a list's search box leaves nothing. An editor's Escape belongs to useUnsavedChangesGuard, which asks first.

Not found

Every route below /clients/:clientId gets its client from ClientBoundary, the layout route at that path: a spinner while the cached list is pending, the client as outlet context once it is there (useRouteClient()), and a thrown NotFoundError('client') once the list has answered without this id โ€” a stale bookmark must not render an editor over undefined. RepositoryBoundary does the same for repositories; EditJobRoute, the restore routes and WebhookEditorRoute throw for their own subject.

RouteError, the errorElement of each area, turns the error into the not-found card. It replaces the page only, so the shell stays and the URL stays where it was; the router drops it on the next navigation. Any other error thrown while rendering is shown there as what it is.

The error is thrown in render, not in a loader: the lists live in the query cache and are kept current by the socket, so a client that is deleted while its page is open is noticed, which a loader โ€” run once per navigation โ€” would not.


๐Ÿ” Authentication

The session is a cookie the browser manages, and AuthProvider (src/features/auth/AuthProvider.tsx) holds only the answer to "is someone logged in".

  • No token in the app. The JWT lives in pbcm_session, an HttpOnly cookie no script can read; the browser attaches it to every request and to the dashboard WebSocket handshake on its own. A second cookie, pbcm_auth, carries no secret and exists so the UI can render the right route without asking the server first.
  • Context (AuthContext.ts, JSX-free so Fast Refresh survives): isAuthenticated, username, login(), logout(). login takes no argument โ€” by the time it is called the server has already set the cookies. username comes from GET /api/v1/me, because the page can no longer read it out of the JWT.
  • Expiry: /api/v1/me also answers expiresAt. AuthProvider sets a timer for it and logs out when it fires โ€” an open dashboard fed by the WebSocket may not send a request, and so not see a 401, for a long time. logout() drops the pbcm_auth flag itself (clearSessionFlag) as well as asking the server to, so a logout request that does not get through cannot leave a reload looking signed in.
  • Local login: POST /api/login with credentials: 'same-origin' โ†’ server sets both cookies โ†’ login(). Login.tsx is the one page using publicApi rather than api, so a wrong password does not get turned into a logout.
  • OIDC: redirect to the provider โ†’ /api/auth/callback โ†’ the server sets the same two cookies and redirects to /. The token used to ride back as /login?token=<JWT>; a query parameter lands in browser history and server logs, which is the reason it moved into the cookie.
  • The flag can go stale โ€” the cookie may outlive an accepted token. It corrects itself on the first call, since lib/apiFetch.ts turns any 401 into logout() centrally and the router re-renders onto the login form.

Server data and client state

Two kinds of state, kept apart:

  • Server data lives in the TanStack Query cache. Everything the server holds -- clients, jobs, runs, repositories, snapshots, webhooks, scheduler status -- is read through a hook in src/queries/, never fetched by a component or a store. lib/queryClient.ts holds the one QueryClient, lib/queryKeys.ts every key.
  • Client state lives in Zustand. That is useUIStore and nothing else: whether the sidebar is collapsed. stores/ holds no server data and makes no request.
  • What the browser remembers is named in one place. The store's key, the theme's and those of every list's view settings live in lib/storageKeys.ts (STORAGE_KEYS) and nowhere else, as pbcm.<area>.<what>. A rename forgets the stored value and is not migrated: each is a preference that is set again with one click.

The defaults (lib/queryClient.ts) follow from "the frontend does not poll": no refetch on window focus, none on the browser's online event, no retry. An entry counts as current for 30 seconds, so moving between pages does not ask again. Three kinds of entry deviate:

Entry staleTime Why
Client list, jobs, latest runs, seen state, scheduler status Infinity The socket writes every change into them.
History pages 0 Read again each time they are opened; a JOB_UPDATE marks them stale, since a page the server cut cannot be patched in place.
Directory listings 0 Nothing pushes into them; read again each time they are opened.
A client's tunnel configuration gcTime: 0 Seeds a form, so it is dropped when the form closes.

The keys are hierarchical. invalidateQueries({ queryKey: queryKeys.clients.all }) reaches a client's jobs, history and directory listings, because a key matches whatever it is a prefix of. queryKeys.test.ts holds the prefix relations the code relies on.

What a hook returns. isPending is true until the entry has answered once -- also with an error. It is what ClientBoundary, RepositoryBoundary and EditJobRoute wait on: an empty list before that says nothing about whether the thing exists.

Actions are mutations. Starting and deleting a job exist once, as useTriggerJob and useDeleteJob in queries/jobs.ts; the client page and the job list both call them. Updating and deleting a client change the list at once and put it back if the server refuses. Saving a form is a mutation too -- useSaveJob, useSaveWebhook, useAddRepository / useUpdateRepository, useCreateTunnel / useUpdateTunnel / useDeleteTunnel -- and the mutation, not the editor, invalidates or rewrites the entries the save changed. No editor calls api to save.

A request made from a form that stores nothing is a plain function beside them, not a cache entry: testWebhook, testTunnelCredentials, testStoredTunnel, probeCertificate, generateEncryptionKey. Its answer is shown where it was asked and is gone with the page.

The settings form is the exception. pages/Settings.tsx loads its values once into a draft and is deliberately not a cache entry: the invalidation after a reconnect would read them again and overwrite what is typed and not yet saved.

The rules a socket message applies to an entry are pure functions in lib/cacheUpdates.ts -- replace one client's jobs, merge a run into the latest-per-job list. They take what is cached and what arrived and return what is cached now, which is why they are tested without a socket or a component.

Job result toasts

hooks/useJobResultToasts.ts, mounted once in AppLayout, turns finished runs from jobUpdate into toasts on whatever page is open. A failure is always reported and stays until dismissed, and so is a missed schedule, as a warning. Dismissing a toast marks nothing as seen. A success or an abort only for a job started from this browser (markJobRunAsked at "Run now"): with many clients, every scheduled run would otherwise raise one. A run is reported once (an agent re-sends finished runs after a reconnect), and not at all if it ended before the page was loaded.

The history page asks for one page (features/history)

The History page is the one list the server filters and pages. GET /api/v1/history takes limit, offset, status, clientId and search and answers with { items, total }; the page holds the rows on screen and no more.

  • The view is the URL. lib/historyView.ts reads page, pageSize, status and search out of the query string and writes them back, so a reload and a shared link land on the same rows. A value that does not parse falls back to the default instead of reaching the server. All four are written through one setter: the router's setSearchParams does not queue, so a filter and the page it resets, written by two setters, would be the second alone.
  • The search is the server's. The page has the search bar the other lists have -- DataMultiView's, which BaseHistoryList shows once it is handed search -- but passes no searchFilter: that would filter the one page on screen. The text goes into the request, and the server looks for it in the job's name and id, the run's id and the client's names. It replaced a client select; clientId is still what the client page's History tab asks with.
  • A search waits for a pause. The field and the URL follow every key; the request is built from the view as it stood 300 ms ago while the search differs (requestedView, hooks/useDebouncedValue). A page or "Failures only" is asked for at once.
  • A filter shows its first page. Changing "Failures only" or the search resets page.
  • The page before stays up while the next one loads (placeholderData: keepPreviousData), so paging does not flash the loading state between two full lists.
  • A page past the end -- from a link, or because the cleanup removed what was on it -- moves to the last page that holds a row.
  • BaseHistoryList takes paging for this: mode: 'server', the page and the total. The embedded lists leave it out and page in the browser, as they hold their whole list.

The last run of a job (features/jobs/lib/lastRun.ts)

Both job lists โ€” the one across all clients and the one on the client page โ€” have a "Last Run" column (the start) and a "Last Status" column beside it, each sortable. The list view adds two fields the table has no room for, "Duration" and "Size", both from runSummary in features/history/lib/runSummary.ts โ€” so the size is there for a backup that succeeded, as in the history. Both lists read GET /api/v1/history/latest through useLatestPerJob(), the cache entry the dashboard's "Backup status" shows and JOB_UPDATE keeps current. lastRunByJob keys it by client and job, since two clients may hold the same job id. The badge is statusBadgeVariant from features/history/lib/statusBadge.ts, the same mapping the history uses.

An empty job list (features/jobs/lib/jobListEmpty.ts)

GET /api/v1/jobs holds a client's jobs only while its agent is connected, so the list across all clients says "No jobs configured yet" only when every client answered. With no client online it says that, and leaves out "New Job" -- a job is saved on an agent. With some offline it names how many could not be asked.

An offline client (features/clients/components/ClientOverview.tsx)

The client page keeps its three tabs while the client is away; what each shows depends on where its data lives:

  • Jobs live on the agent. The tab says so instead of "No jobs configured yet", and the card shows โ€“, not 0.
  • Snapshots come from the repositories and are listed as always. Restore is disabled: the form browses the client's file system for the target.
  • History is read from the server's own table โ€” useStoredClientHistory, the paged GET /api/v1/history?clientId=โ€ฆ โ€” because GET /clients/:id/history asks the agent. Online, the tab shows the agent's list, which JOB_UPDATE patches in place.

The tab lists every run, restores included; clientTab(clientId, 'history') in lib/paths.ts is the link the notice of a started restore uses.

The dashboard (features/dashboard)

/ is a page of its own, DashboardOverview. It reads only what the shell has cached anyway, plus one page of the history.

The five cards sit in the library's StatCardGrid, which picks the columns by the row's own width, not the window's: beside an open sidebar in a 1280px window they go 3 + 2, five across only where a card has room for "Degraded" beside its icon.

  • "Backups", the first card, is the status the findings add up to (backupStatus in lib/findings.ts): Critical with an error among them, Degraded with warnings only, Healthy with none, Unknown while the clients load or none is registered. It leads nowhere; the findings are below it.
  • Three StatCards, each the way to its list: "Hosts online" (/clients), "Repositories online" (/repositories), "Jobs active" (/jobs). The numbers come from lib/dashboard.ts -- clientCount, repositoryCount, activeJobCount -- and AppLayout builds the sidebar's badges from the same functions, so a card and its badge cannot disagree. "Jobs active" is the jobs on online clients; there is no total, because the server knows a client's jobs only while its agent is connected. Below the number, clientSummary and repositorySummary say who is missing; a client that still awaits its agent is named apart and does not turn the icon yellow.
  • A fourth card, "Errors / Warnings", counts what the user has yet to mark as seen: the failed runs and the missed ones, from useUnseen (problemSummary says below the number what it is made of). The section of the same name is rendered only while it has something to show -- or after a click on the card, which with nothing left opens it to say so and closes it again; with something left the click scrolls down to it. The card carries aria-expanded for the section.
  • Errors / Warnings, the section: one BaseHistoryList of the unseen runs -- useGlobalHistory with unseen: true, paged by the server. Its key lies below queryKeys.history.lists(), which every JOB_UPDATE marks stale, so a new failure appears without a rule of its own. With nothing to list: one EmptyState.

A run leaves that list when the user marks it as seen, and by nothing else -- not by opening a page, and not because a later run of the same job went well. Every row has "Mark as seen", the list's header "Mark all as seen"; the history page offers the same two. Both are mutations in queries/history.ts (useMarkRunSeen, useMarkAllSeen), reached through features/history/hooks/useMarkSeen.ts, which also says when a mark did not reach the server. The answer to a mark is the new count, and the history lists are read again, since they carry unseen per run.

The mark is the user's own and lives on the server, so the card, the list and the dot on "History" in the sidebar count the same runs in every browser -- and what one user has seen is still new to another. A new user starts with everything so far marked as seen.

The count is never raised in the browser. A run that fails or is missed invalidates it (refreshUnseen, called by the result toasts): an agent that reconnects sends a run again, and a count kept here would say two for what the server holds as one.

Needs attention lists the findings, one per line, each a link to where it can be dealt with and with how long it has lasted below (FindingList). The card is there only while there is a finding. buildFindings in lib/findings.ts makes them, through hooks/useBackupOverview.ts, out of what the cache holds -- clients, repositories, jobs and the last run of every job:

Finding Severity When
client-offline error A registered client is not connected. A client that awaits its agent is none.
repository-offline error A repository's check did not answer. unknown and loading say nothing yet.
run-failed error The last run of a job failed.
tunnel-error warning A client's SSH tunnel is in error.
client-without-jobs warning An online client whose list the server holds has no job.
run-aborted, run-missed warning The last run of a job was aborted or missed.
run-overdue warning A scheduled job's nextRunAt passed more than MISSED_AFTER_MS ago and no run is under way: the agent's scheduler has stopped moving it on.

A finding is a state, a run under "Errors / Warnings" an event. The finding goes once the problem does -- a later run that succeeds takes run-failed with it -- while the run stays in "Errors / Warnings" until it is marked as seen. An offline client gets client-offline and none of its own besides: the server does not know its jobs while it is away. Its failed runs are still found, since they come from the server's history.

client-without-jobs needs to tell "no job" from "not reported yet", which an empty list cannot. The jobs entry therefore holds, next to the jobs, the clients whose list the server holds (GlobalJobList.known): the clients GET /api/v1/jobs answers for, and those whose JOBS_UPDATE carries known: true (applyClientJobs in lib/cacheUpdates.ts). The empty list the server sends when an agent drops carries known: false.

Backup status, at the bottom, is the last run of every job (GET /api/v1/history/latest through useLatestPerJob), problems first and within each the oldest first (byBackupStatus in lib/backupStatus.ts), so among the successes the backup that has gone longest without a new one is on top. existingJobRuns leaves out the runs of deleted jobs, as far as that can be told: only for a client whose list the server holds is a job that is not in it gone. It used to be "Last Activity" on the Jobs page, whose list has the same runs as columns.

Missed runs come from the agent, as history entries with the status missed (see client.md). The dashboard used to work them out itself from nextRunAt; that is gone, so a missed run is an event that can be marked, not a state that disappears when the job next runs.

useGlobalJobActions (features/jobs/hooks) holds what a row of jobs across all clients can do -- start, delete after asking, name its client -- for the job page.

Aborting a run (hooks/useAbortRunAction.ts)

A run that is under way can be stopped from two places, and both call the one hook: the expanded history row has an "Abort" button, and a job row whose last run is still going shows "Abort Run" in the slot that otherwise starts the job -- starting it again would only queue a second run. canAbortRun (features/history/lib/runAbort.ts) decides whether a row offers it: running or queued, and not while the run reads back its snapshot, which the agent refuses.

The hook asks first (describeAbortRun -- a backup leaves no snapshot, a restore leaves its target partly written) and sends useAbortRun from inside the dialog, so a refusal -- the run ended a moment ago, the client dropped -- is read where the abort was asked for. Nothing is written into the cache: the agent ends the run and its JOB_UPDATE turns the row to abort. A list whose rows do not name their client (a client's own page) passes clientId to BaseHistoryList / BaseJobList.

Job history rows (features/history/components/BaseHistoryList.tsx)

Every history list โ€” "Recent Activity" of a client, the History page, "Backup status" on the dashboard โ€” renders through BaseHistoryList, so the following holds in all of them:

  • What a row says unopened. Below the name: the event kind and, for a successful backup, the size of its snapshot; at the right the start and how long the run took, as one statement (features/history/lib/runSummary.ts). A run still going has no duration. The run id is in the expanded row, above the output โ€” it is looked up, not scanned.
  • The client's name is a link to its page (components/EntityLink), wherever the list shows one. A run outlives its client; one whose client is gone reads Unknown Client and links nowhere.
  • Reading the snapshot. After a successful backup the agent reads back its snapshot, and the run stays running meanwhile. The jobUpdate for that step carries phase: "snapshot", and the status badge reads Reading snapshot instead of Running. The final update sends phase: null explicitly: the cache merges updates with a spread, and a phase left out would stay. useJobResultToasts needs nothing for this โ€” it reacts to final statuses only.
  • Snapshot details. The expanded row of a successful backup appends the snapshot to the log, below the CLI's own output (features/history/lib/runSnapshotLog.ts): a line Reading Snapshot <snapshot>, then one line per archive with its size and, if encrypted or signed, the crypt mode โ€” docker.pxar.didx 1.99MiB (encrypted). The manifest is left out. The details are part of the history row, so nothing is loaded on expand. A run of an older agent has no snapshot and shows its log alone.
  • No details. A successful backup whose snapshot could not be read gets a warning badge No snapshot details next to its status, and the reason in the expanded row. It does not count towards the dot on "History", which stays for failed runs.

Sizes go through formatBytes in utils.ts (binary units, as the PBS shows them).

The log itself is RunLog: as high as its text up to a limit, scrolling beyond it. While a run is writing it stays at its end, unless the operator scrolled up -- isAtEnd in lib/logScroll.ts decides that. Its copy button selects the text where the clipboard API is missing, which is every dashboard reached over plain HTTP.

Dates and distances (utils.ts, lib/time.ts)

  • A date follows the browser's locale -- formatDate. It takes a locale only so a test does not depend on where it runs.
  • "Last seen" and "Next run" are distances: formatRelativeDate writes 3 minutes ago and in 5 hours, in English whatever the browser's language, because it is a phrase inside English labels. Everything that is looked up rather than glanced at -- a run's start, a snapshot, a token's expiry -- stays a date.
  • A distance needs useNow(), which renews once a minute. The dashboard does not poll, so nothing else would re-render it. It is one clock for the whole page (lib/clock.ts, read through useSyncExternalStore): every reader gets the same number of milliseconds on the same tick, and the timer runs only while something reads it.

Talking to the server (lib/api.ts)

Every request goes through api, and every response is parsed against a Zod schema from @pbcm/shared before a component or the cache sees it:

const clients = await api.get('/api/v1/clients', ClientListSchema);
await api.put(`/api/v1/clients/${id}`, { displayName }, undefined, { fallback: 'Failed to update client' });
  • api.get(path, schema), api.post(path, body?, schema?), api.put(โ€ฆ), api.delete(path). A call without a schema expects no body and resolves to void.
  • A refused request throws ApiError, carrying the server's own error text and the HTTP status. fallback is what it says when the body has no text. A caller that treats a status as an ordinary state branches on it -- ClientTunnelCard reads a 404 as "no tunnel yet".
  • An answer that does not match its schema throws ApiError too (Unexpected response from GET โ€ฆ); the Zod issues go to the console.
  • A 401 is not an ApiError: apiFetch underneath logs out and throws SessionExpiredError.
  • publicApi is the same client over plain fetch, for the three unauthenticated endpoints (/api/login, /api/auth/logout, /api/auth/config), where a 401 is a refusal like any other.

There is no .json() outside lib/api.ts and no as on response data. The schemas live in shared/src/responses.ts, one per shape, and describe what the server sends -- for a row read out of SQLite that is null, not a missing key. They are deliberately looser than the schemas that guard input: BackupJobViewSchema checks a job's shape, not the editor's rules, so a job saved by an older agent still shows.

A new endpoint needs its schema there first. Adding a field to a response without adding it to the schema is harmless but useless: Zod strips what a schema does not name.

Two realtime channels, and why

Every message is first read by features/app/lib/dashboardMessages.ts, which parses it against DashboardMessageSchema from @pbcm/shared; one that does not match is dropped and reported once per type. WebSocketProvider then dispatches in a switch over message.type that ends in assertNever -- a message type added to the schema without a case there fails npm run typecheck -w server/frontend.

Updates from /ws/dashboard reach the app on two paths, and the split is deliberate.

Into the query cache go CLIENTS_UPDATE, TUNNEL_UPDATE, JOBS_UPDATE, JOB_UPDATE, JOB_NEXT_RUN_UPDATE, SCHEDULER_STATUS_UPDATE and HISTORY_SEEN. These are state: a handful of messages describing something the whole application reads. The handler writes them with setQueryData and an updater function, which changes an entry only if it has been read -- a JOB_UPDATE before the first fetch does not create half a list. CLIENTS_UPDATE is the one that sets its entry outright: it carries the whole list.

WEBHOOKS_UPDATE carries no payload and invalidates instead: a page that shows the webhooks reads them again now, any other finds them stale when it opens. A JOB_UPDATE that reports a successful run does the same to every repository's snapshots.

After a reconnect everything the server does not push on connect is invalidated once, in socket.onopen. What the server pushed while the socket was down is lost, and only CLIENTS_UPDATE is sent again on connect. isPushedOnConnect in lib/queryKeys.ts names that one entry -- clients.list(), not clients.all, because a client's jobs, history and directory listings live below it and are not sent. Everything else on screen is read again; the rest is read when it is next shown. A cache area the server starts pushing on connect has to be added to PUSHED_ON_CONNECT, and queryKeys.test.ts holds the list.

JOBS_UPDATE is there because the server's job cache is tied to the agent connection -- GET /api/v1/jobs returns nothing for an offline client. Its known flag says whether an empty list is the client's (it has no job) or the server's (the agent is gone). Without the broadcast, a dashboard that was already open kept the list it fetched on mount, and the sidebar's job count stayed at whatever it was when the page loaded.

Through lib/realtimeEvents.ts go logUpdate and jobUpdate. Log lines are a stream: they arrive many times a second for exactly one visible component, and holding them in the cache would re-render every subscriber on every chunk. jobUpdate is both -- the cache takes the run as the new state of its row, and the event is for whoever reacts to the moment itself, which today is useJobResultToasts.

The emitter carries a typed event map, its payload types taken from @pbcm/shared โ€” the same contracts the WebSocket messages are validated against, so the channel cannot drift from the socket that feeds it. One payload is converted on the way in: JOB_UPDATE carries the agent's StatusUpdatePayload, and historyUpdateFrom turns it into the history row the lists hold (jobConfigId for jobId, null for what a running job has not reported). subscribe(type, handler) returns the unsubscribe function, which a useEffect can return directly.

Underneath sits mitt (~200 bytes), not a hand-written registry. The first version of this module was one: a Map<string, Set<โ€ฆ>> that needed a cast, because a { [K in keyof Events]?: Set<Handler<K>> } cannot be written to through a generic key. mitt is generic over the event map and has no such gap, so the cast is gone. Two things stay in this module because mitt deliberately omits them โ€” the unsubscribe function, and wrapping each handler so one that throws cannot stop the ones behind it. The event map is a type and not an interface for a compiler reason: mitt constrains it to Record<EventType, unknown>, and an interface satisfies no index signature it does not declare.

This was window.dispatchEvent(new CustomEvent('pbcm:log_update', โ€ฆ)) until the ARC-2 cleanup. The decoupling was right; the transport was not โ€” the payload type was asserted at each listener rather than guaranteed, the events were invisible to the React DevTools, and every subscriber needed an as EventListener cast to compile.


Generic UI components (Buttons, Inputs, Cards, etc.) are primarily sourced from the external library @stefgo/react-ui-components. Components within src/components/ in this project are reserved for domain-specific or complex composite views.

UI Library Integration

The library ships a preset that carries both the design tokens and its own content glob. Tailwind merges darkMode and safelist from a preset but not content โ€” a content in the project config replaces the preset's entirely, so the library's glob has to be spread back in by hand:

presets: [preset],
content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
    ...preset.content,
    ...localUiContent,
],

Without ...preset.content, every class only the library uses โ€” w-64 for the sidebar, its grid and positioning utilities โ€” is missing from the output and the layout collapses.

localUiContent is the VITE_USE_LOCAL_UI path, which builds against a sibling checkout of the library instead of the installed package. It swaps three things that have to move together: the bundler's module resolution, this content glob, and the preset itself. The preset was the one that used to stay behind โ€” it carries the theme, so a local build ran new components on the published theme, and the mismatch surfaced as a colour that was in neither source tree.

Data Views

Most data-driven lists utilize a common base to provide consistent loading, error, and empty states. We use a Base Component Pattern (e.g., BaseJobList, BaseRepositorySnapshotList) to share logic across different views.

  • DataMultiView: The standard container that allows switching between DataTable and DataCard layouts. A list passes it one columns definition for both views, never tableDef next to listColumns: two descriptions of the same column drift, and BaseJobList asked "is this job's client online" in twelve renderers for six columns.
    • The heading is also the list label; list: { label } renames it, label: null drops it.
    • table: false / list: false leaves a column out of one view. A sort's colIndex counts the table's columns, so it skips the ones with table: false.
    • render(item, view) serves both views; view is for the cell that has to differ.
    • What every list shares comes from the library as well: listGroups(), the two blocks of a list row, actionsColumn(render), the last column of every list, and listPagination(PAGE_SIZE.โ€ฆ), its paging.
  • DataTable: A generic, column-based tabular view for structured data.
  • DataList: A simpler, row-based list view.
  • PaginationControls: Integrated pagination logic for larger datasets.
  • LoadingIndicator and components/QueryError: what a page shows while its data is on the way, and when it could not be read -- what failed, and the server's own words for why. No page builds its own.

Actions & Buttons

  • ActionButton: Reusable button for common actions (Run, Edit, Delete) with built-in color variants and tooltips.
  • ActionMenu: "Kebab" menu (Three dots) for context-sensitive actions.
  • DataAction: Wrapper to group multiple actions for a specific data item.

Confirmations

Every question before an action, and every notice after a failed one, goes through useConfirm() from the library. ConfirmProvider wraps the routes 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 nothing calls window.alert or window.confirm.

  • confirm(options) resolves true or false. An action that is quick to hand off โ€” leaving an editor, distributing a fingerprint โ€” runs after the await.
  • An action whose outcome is worth waiting for โ€” deleting a client, a job, a repository, a user, removing the tunnel โ€” 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.
  • alert(describeFailure(title, error)) from utils.ts reports a failure of an action that was not asked about first, such as running a job or the cleanups in Settings.

The texts live in a confirmations.ts per feature (clients, jobs, repositories, users, webhooks), one describeX(...) per action, returning the complete options including variant. The discard question every editor asks is the one cross-feature entry, in components/confirmations.ts, with each editor's own consequence; useUnsavedChangesGuard is the only caller. A component decides that it asks, never what the question says or whether it is danger.

Forms and Save Actions

One hook for every editor: useEntityForm

An editor's state is three things, and hooks/useEntityForm.ts holds them: the draft (what is typed), the baseline (what it was when the form opened or was last saved), and how the save went. A component keeps nothing else about what it saves in useState.

const form = useEntityForm({
    schema: WebhookInputSchema,     // the schema the backend parses the request with
    initial: () => draftFrom(webhook),
    toInput: inputFrom,             // draft โ†’ request: text to numbers, lines to a record
    fieldOf: webhookFieldOf,        // issue path โ†’ draft field (timeoutMs โ†’ timeoutSeconds)
    rules: webhookRules,            // what only the form knows
});

<Input value={form.draft.url} onChange={(e) => form.set('url', e.target.value)} error={form.errors.url} />
<Button type="submit" disabled={!form.canSave} isLoading={form.isSaving}>Save</Button>

await form.submit((input) => saveWebhook({ id, input }));
  • The draft is checked against the backend's schema, from shared, on every render. What the server would refuse is said at the field before anything is sent, in the schema's own words. A schema an editor needs lives in shared, not in the controller that parses with it -- ClientUpdateSchema, RepositoryInputSchema, TunnelCreateSchema, TunnelUpdateSchema moved there for this.
  • The draft is as typed; the schema describes the request. A timeout is text in seconds in the field and milliseconds on the wire. toInput builds the request, fieldOf says where an issue on the request is shown. toInput throws DraftFieldError(field, message) when it cannot build one at all (a header line without a colon).
  • rules are for what a schema cannot say: a secret that is required when creating and optional when editing, a schedule that needs a start, and a required field in words instead of "too small". A rule wins over the schema's message for the same field.
  • A disabled save button has a reason on screen. canSave is "changed, valid, not being saved". errors is empty until the form is dirty -- an untouched form has a disabled button because there is nothing to save -- and from the first change every field that is wrong says so. What belongs to no field is formError, shown in the footer beside saveError.
  • saved holds only while the draft equals the baseline, so no onChange has to reset it. submit makes the stored draft the new baseline; rebase is for a draft that differs once stored (the repository's secret field goes back to empty).
  • significant narrows what counts as a change when part of the draft is sent nowhere: an address under an unticked box, a key mode switched without a key.

Everything the hook computes is a pure function in lib/entityForm.ts, and each form's toInput, rules and fieldOf are pure functions in its feature's lib/ (clientForm.ts, tunnelForm.ts, jobForm.ts, repositoryForm.ts, webhookForm.ts) -- that is where they are tested, since the tests run without a DOM.

UI state is not the draft. An open file browser, a half-typed archive, the result of a connection test, a "copied" tick: none of it is saved, so none of it makes the form dirty, and it stays in the component that shows it. The job editor's archive and exclusion panels hold their own entry until it is confirmed; only then does it enter the draft.

A field is one of the library's controls with its error prop. Where there is no control to hang the message on -- the job's archive list, its repository card -- the list is wrapped in a FormField without a label, which renders the message and wires aria-describedby.

pages/Settings.tsx and the add-client wizard are not on the hook.

One screen, one save, one resource. A screen that writes to a second API resource has stopped being a form and has to be re-cut โ€” it must not grow a second save button inside the same <form>.

Two shapes are allowed:

  • One submit that covers everything. The handler issues both requests and reports one result. Right when the parts are not independently useful.
  • Two separate Cards, each with its own action. Right when they are โ€” a client's name and its SSH credentials are edited on different occasions and fail for different reasons.

What is never allowed is the middle ground: a nested section with its own save button sitting above the form's primary button. The primary button then silently ignores half the fields, and Enter in any nested input submits the outer form rather than the section the cursor is in. ClientEditor carried exactly this defect from the moment the SSH tunnel section was hung into its existing <form>. The fix went one step further than one Card per endpoint: the tunnel is now its own surface (ClientTunnelEditor), so there is no shared form left to get this wrong.

Three rules follow from the same reasoning:

  • A "test" button must test what is on screen. If it validates stored state instead, it reports success for a configuration the operator has just replaced. Check which state the endpoint reads โ€” POST /v1/tunnel/test takes every parameter from the request, while POST /v1/clients/:id/tunnel/test reads the key from the database and takes host, port and user from the request. That split is what lets the card test edited values without the write-only key ever leaving the backend; it is not a licence to send one endpoint's body to the other.
  • The way out belongs to whoever holds the form. ClientEditor's exit used to hang off ClientIdentityCard because that card happened to be first. With a second card below it, working the page top to bottom ended with no way out in reach. Closing asks about the form, so it sits with useEntityForm and useUnsavedChangesGuard -- in ClientEditor for the identity card, in ClientTunnelCard itself, whose form only exists once the stored configuration has arrived.
  • A new mode is a review of its siblings. Adding a variant such as SshKeyMode's keep changes what the neighbouring components receive โ€” keep leaves privateKey empty, which is why SshHostSetupSnippet renders a command with a hole in it. When a mode is added, walk every consumer of that state under the new value.

๐Ÿงฉ Feature Details

ManagedClients (features/clients)

This is the "Controller" for the client overview. It connects the UI (ClientList) with the logic (queries/clients.ts, API calls).

  • Functionality:
    • Displays list of clients.
    • Deletes clients, reconnects outbound ones.
    • Navigates to the three editor routes: /clients/new from the one + Add button (the connection mode is the wizard's first step), /clients/:id/edit and /clients/:id/tunnel from the row actions. It holds no editor state of its own โ€” it used to swap four surfaces through the same div, which meant the URL described none of them.

Client Editor (ClientEditor.tsx)

A page at /clients/:clientId/edit, and a container rather than a form: it selects the live client from the cache by id (useClient) and renders the card for the one resource it owns.

  • ClientIdentityCard โ€” header (StatusDot, id, last seen), connection mode Badge, agent version, display name, target address, Save Client. The form is held by ClientEditor and checked against ClientUpdateSchema from @pbcm/shared, the schema PUT /clients/:id parses with -- it normalises the target address through normaliseTargetAddress, so a rejected address never has to make the round trip.

The SSH tunnel is not in this editor. It is a different resource on different endpoints, and it is not part of what a client is but of how a PBS is reached from it โ€” a question that comes up long after the client exists. It has its own surface, reached from the client list.

ClientTunnelEditor / ClientTunnelCard

Opened from the client list's row action โ€” Add Tunnel, or Edit Tunnel when client.tunnelConfigured โ€” for every client, in either connection mode: the tunnel is a route to the PBS and is optional on both sides of the WebSocket. Setting one up and changing one are one action, not two: the same form on the same endpoints.

ClientTunnelEditor supplies the live client; ClientTunnelCard is the work, the close control included. It is two components: a shell that waits for GET /tunnel and shows the loading and error states, and the form, mounted with the first answer and keyed by the client -- so a later answer (the cache is read again after a reconnect) never overwrites what is being typed. The card holds the SSH credentials and nothing else โ€” stored means available, and which runs take the tunnel is set per job in JobTunnelSettings and per restore in SnapshotRestoreEditor. Two states in one card, keyed on what GET /tunnel answers: a 404 is not an error but "no credentials yet", and the card becomes a setup form whose Test & Set Up does test and POST in one action. Remove deletes them after a confirmation.

A StatusDot beside the title, exactly as in ClientIdentityCard โ€” whether a connection is up is answered in one idiom on every client surface, and the tunnel's four states map onto the dot's four tones; it appears only once there is a tunnel to report on. Forwards and lastUsedAt come from client.tunnel, which TUNNEL_UPDATE keeps current in the cache; the GET /tunnel call supplies only the stored configuration. Test Connection sends the form's values, Save Tunnel writes them, and a fingerprint mismatch surfaces a Trust this host key block (see docs/tunnel.md).

Creating and removing a tunnel read the clients again (useCreateTunnel, useDeleteTunnel): tunnelConfigured is what the row's action label and its tunnel badge read, and it has just changed. A port that is not a number is refused at the field; it is not sent as 22.

Leaving an editor (every editor)

The exit is an ActionButton with an X in the card header โ€” the same place AddClientWizard and ClientOverview put theirs. This replaced a sticky bottom-0 bar; the header is the one part of a card that stays in reach at every scroll position without floating over the content. ClientTunnelCard renders it in all of its states, the load error and the loading placeholder included โ€” an exit that disappears when a request hangs is an exit that is missing when it is needed.

Unsaved work is asked about in one place, hooks/useUnsavedChangesGuard.ts, through the router's useBlocker. Every way out is a navigation, so every way out asks the same question once: the X, Esc, an entry in the sidebar, the browser's back button. Before, each editor carried its own copy of "ask, then navigate" behind the X and Escape, and the sidebar and the back button discarded without a word.

const { close, leave } = useUnsavedChangesGuard(form.isDirty, 'repository');
  • close() navigates to the parent in the route tree (useBackPath). While the form is dirty the blocker stops it and describeDiscardChanges asks through useConfirm().
  • leave() does the same without the question. It is for the navigation that follows a save: the form is only clean on the next render, and the blocker would still see the one before.
  • Esc does what the X does, guarded by e.defaultPrevented so a select, an autocomplete or an open confirmation keeps Escape for itself. onEscape lets a page use the key up first โ€” the job editor closes an open panel back into the form.
  • A reload or a closed tab is not a navigation the router sees; while the form is dirty the browser's own beforeunload prompt covers it.

A new job, repository or webhook leaves once it is saved โ€” a form that has produced its entity would only produce a second one. Editing a client, a tunnel, a repository or a job stays and says so in the footer; the webhook editor leaves after every save.

StatusDot (components/StatusDot.tsx) takes a tone and a label separately, because the domains name the same state differently โ€” a client is online, a tunnel is up. The component knows four visual tones and no vocabulary; the caller brings its own word, which the dot carries in aria-label/title so no badge beside it has to repeat it. Every status dot in the lists (clients, repositories, snapshots, jobs) is one; a hand-built w-2 h-2 rounded-full dot has no label for a screen reader and drifts from the others' look.

Add-Client Wizard (features/clients/components/add-client/)

Built on Wizard and Stepper from @stefgo/react-ui-components. The flow forks after step 1:

1 Connection โ”ฌโ”€ inbound  โ†’ 2 Client (name, allowed IP/CIDR) โ†’ [Add Client & Generate Token] โ†’ token dialog โ†’ client page
             โ””โ”€ outbound โ†’ 2 Client (name, address, secret) โ†’ [Create]

The inbound branch ends at step 2: Add Client & Generate Token creates the client (POST /v1/clients/inbound), which then waits for its agent, and shows its registration token in a RegistrationTokenModal; closing that dialog opens the new client's page in place of the wizard. The token is deliberately not a step โ€” the client exists on the server from the moment it appears, so there is nothing left to go back to, and a step that created it on entry created a second one on every remount. The display name is required (isInboundComplete): until the agent reports its hostname it is all the client is known by.

The wizard covers one question: how the server and the agent reach each other. An SSH tunnel is no part of it โ€” that is the route to the PBS, it stays revisable for the client's whole life, and it is added afterwards from the client list. Tying it to the wizard tied a reversible decision to an irreversible one and made it look like a property of the mode.

  • AddClientWizard.tsx โ€” the framing Card, the step lists per branch, and both create requests (createInboundClient in queries/clients.ts for inbound, POST /v1/clients/outbound for outbound). It is the /clients/new route โ€” a page in the dashboard work area, not a modal, like the two editors. Its onClose / onCreated / onInboundCreated come from the route, which navigates back, refetches the list or opens the new client. The step index is controlled here: swapping the step array is the branch, and only this component knows it happened.
  • useAddClientForm.ts โ€” all form state, one level above the steps. Wizard renders the current step alone, so a step holding its own inputs would lose them on the way back. Inbound and outbound are separate objects, so switching the mode and switching back costs nothing.
  • ../RegistrationTokenModal.tsx โ€” the issued token, in a modal, shared with the client page. Neither the backdrop nor Escape dismisses it: the server keeps the token hashed, so this is the only time it is shown in full.
  • steps/ โ€” one file per step, all presentational. Both branches are two steps long: the connection mode, then either the inbound registration details or the outbound agent's address and secret. Create ends either one. A failure is reported into the last step rather than moving the flow on. The wizard still clamps its controlled step index, because going back and picking the other mode swaps the array underneath it.

The inbound details (display name, allowed IP or CIDR network) are stored on the client right away; the agent, registering unattended, only presents the token โ€” see POST /api/v1/clients/inbound in api.md.

A client that waits for its agent

An inbound client created in the wizard has no hostname and has never been seen (awaitsRegistration in features/clients/lib/registration.ts). The list says Awaiting registration, and the client page shows a notice above its tabs (registrationNotice): whether the token issued for it is still valid, with a button for a new one, which goes out without asking. The header shows Awaiting registration in place of Offline and, in its details, until when the token is valid. A tunnel can be added already; jobs wait for the agent, because they live on it. For a registered inbound client the menu's Re-register Agent issues a token too, after asking (describeReregister): the agent that registers with it takes the client over, and the one connected until then is cut off at that moment. Both show the token once in the RegistrationTokenModal; while it is on screen, useEscapeToLeave is off.

ClientOverview (features/clients)

The detail view of a client. It consists of multiple tabs/sections:

  1. Stats: Tiles for jobs, snapshots, and history (also act as a tab switcher).
  2. Configured: List of configured backup jobs (ClientJobList) and editor.
  3. Snapshots: List of available snapshots (RepositorySnapshotList). A restore can also be started here (SnapshotRestoreEditor). This component is also reused in the Repository Overview for a global view of all snapshots in a repository.
  4. History: Execution logs and, for a backup, the snapshot it created (ClientHistoryList, see Job history rows).

Job Editor (ClientJobEditor.tsx + job-editor/)

One section per aspect of a job, all reading from JobFormContext rather than props: repository, archives, exclusions, encryption, tunnel, schedule. The context is small: the form (useEntityForm over a JobDraft), the client id, the agent's time zone, and whether a tunnel is available.

  • The draft is one object, built by emptyJobDraft(now) or jobDraftFrom(job, now) in features/clients/lib/jobForm.ts and turned into the request by jobInputFrom. It is checked against BackupJobSchema; jobRules adds what the schema leaves open, since the backend parses a job with every key optional: a name, at least one archive, a repository, a start for an enabled schedule, a key for enabled encryption.
  • Which panel is open is the page's state (JobEditorView in JobEditorPage): the form, the client list, the repository list, the archive editor or the exclusion editor. It sits there because the page decides what Esc closes -- an open panel first, the page after.
  • A panel's own entry is its own state. JobArchiveEditor and JobExcludeEditor keep the path, the name or the pattern and the directory the browser stands in, list that directory themselves (useClientFiles), and write into the draft only on confirm.

  • JobExcludeList / JobExcludeEditor โ€” the job's --exclude patterns. The CLI reads a pattern relative to each archive's root, so a directory picked in the file browser is rebased by excludePatternFromPath onto the deepest archive containing it (/home/stefan/.cache in /home โ†’ /stefan/.cache); a path in no archive is refused. Patterns can also be typed directly. They apply to every archive of the job.

  • JobTunnelSettings โ€” whether this job reaches its repository through the client's SSH reverse tunnel. Per job because one client can have a PBS it reaches directly and another it only reaches through the detour; the credentials are the client's, the choice is the job's. The switch is disabled while tunnelAvailable is false โ€” useJobForm reads client.tunnelConfigured through useClient for that โ€” so the impossible combination is not offered rather than rejected by the backend afterwards. A job already set to use the tunnel keeps the switch usable even without a client row in the cache, or the setting could be seen but never turned off.

  • JobScheduleSettings โ€” the interval is held as typed (JobDraft.interval is a string), so the field can be emptied on the way to another number; jobInputFrom builds the number and jobRules says "At least 1." at the field. Below the weekdays the next three runs are listed: previewRuns calls upcomingRuns from shared, the calculation the agent's scheduler runs. It is made on the browser's clock; where the agent keeps another time zone the preview says so.
  • jobInputFrom always sends tunnel, including as false: omitting it on an update would leave the stored route standing, and switching the tunnel off would silently not take.

Restore Flow

The restore process is complex and distributed across:

  1. useRepositorySnapshots (queries/repositories.ts): Loads available snapshots from the PBS.
  2. A route of its own, below the client or below the repository. The URL names the snapshot (type, id, time); SnapshotRestoreRoute reads it from the same snapshot list the page it was opened from shows.
  3. SnapshotRestoreEditor (in features/repositories):
    • Laid out like the editors -- Card, the X in its header, one button under the fields, and the outcome said next to that button -- but not on useEntityForm: nothing is saved, so there is no baseline, and a hook that holds its messages back until something changed would leave a form that opens filled in but for the target with a dead button and no reason. It calls checkDraft (lib/entityForm.ts) itself, against RestoreRequestSchema from shared -- the schema the backend parses the request with -- and shows what is missing from the start.
    • lib/restoreForm.ts holds the pure parts: the draft a snapshot opens with, the request built from it (restoreInputFrom), the rules only the form knows (restoreRules: a client, an online one, an archive, a target).
    • Archives (e.g., root.pxar), all preselected.
    • The client: picked in the form, or locked to the one the route names. An offline client is refused at the field -- the restore runs on its agent.
    • The target directory is typed or picked in the file browser; a directory that does not exist yet cannot be clicked.
    • The start asks first (describeRestore: client, target, archives, and that files already there are replaced), through useConfirm(). It is useStartRestore in queries/jobs.ts. No question on leaving: the selection is rebuilt in seconds.
    • Restore through the SSH reverse tunnel โ€” the same question JobTunnelSettings asks of a job, asked here because a restore has no stored config to carry it. It follows the selected client, not the one the editor was opened for, since the client can still be swapped in the form. Shown only when that client has credentials โ€” a client with no tunnel has no choice to make, and an inert switch would raise a question the operator cannot act on from here โ€” and defaulted to on, because a client that has a tunnel usually has it for want of a direct route.
  4. POST /api/v1/clients/:id/restore: Triggers the restore command on the client. The tunnel choice travels in that one request as tunnel: { required }; the backend rejects a request asking for a route the client has no credentials for.

๐ŸŽจ Styling & Theming

  • Tech Stack: Tailwind CSS v3 with darkMode: "class".
  • UI Library: @stefgo/react-ui-components โ€“ all generic components (Card, Input, Select, Button, DataTable, StatCard, etc.) come from this library.
  • Tailwind Preset: The library ships a tailwind-preset.js that defines all design tokens. PBCM's tailwind.config.js uses it as a preset and also scans the library's dist/ for class usage:
presets: [require("@stefgo/react-ui-components/tailwind-preset")],
content: ["./src/**/*.{js,ts,jsx,tsx}", uiLibDist],

Design Tokens

All tokens are CSS custom properties defined in the library (--ruic-*) and exposed as Tailwind classes:

Token class Usage
bg-app-bg Main page background
bg-card Card and panel surfaces
bg-card-header Card header background
text-text-primary Primary text color
text-text-muted Secondary / label text
border-border Standard border (always with border)
bg-hover Interactive hover backgrounds
text-primary Brand accent color (Proxmox Orange)
shadow-premium Elevated card shadow

Important: border-border sets the colour, border only sets the width โ€” a border without border-border falls back to currentColor. There is no dark: twin: the library redefines --ruic-border inside its .dark block, so one class is correct in both themes.

Opacity modifiers: Only primary supports / opacity modifiers (e.g. bg-primary/10). Other tokens use plain CSS variables and cannot be used with /.

CSS Component Layer (index.css)

Project-level reusable classes defined via @layer components:

Class Usage
.glass-card Frosted glass modal overlay (Login, token modals)
.field-label Label styling for native <select> and custom form groups

UI Components

All forms use the Input and Select components from the library, which provide consistent label, hint, error, disabled, and dark mode styling out of the box. Native <input> elements are only used for checkboxes.

UserDialog is the library's Modal and holds its draft through useEntityForm (features/users/lib/userForm.ts); RegistrationTokenModal and the detail headers (ClientOverview, RepositoryOverview) use the Card component for consistent framing.

  • Dark Mode: the library's ThemeProvider, mounted in App with STORAGE_KEYS.theme, toggles the dark class on <html>; useTheme() comes from the library as well. There is no dark: variant anywhere in src/ โ€” and that is the point: every role is defined once in the preset and redefined per theme in its .dark block, so bg-card resolves correctly in both. A dark: twin in this codebase is a sign that a palette colour was used where a role belongs.