Angular dashboard (Angular Material) for the .NET ServiceManager backend. Backend:
http://localhost:5193/— Swagger:ServiceManager-swagger.json. Status: planned — implementation starts tomorrow.
| Topic | Decision |
|---|---|
| UI variant | C — Service cards + SSE live job monitor |
| Refresh strategy | Manual only (initial load + Refresh button + re-fetch after every action) |
| Theme | Dark by default, toggleable to light (Material 3, class-based) |
| Job result colors | done → green; failed → red; canceled/timeout/dependency/skipped → amber |
| Unit test framework | Jasmine (ng new default) + Material test harnesses |
| Antiforgery | No impact — .NET 9/10 only enforces antiforgery on minimal API endpoints that bind form data; all backend POSTs are body-less (verified against MS docs) |
| CORS | Backend CustomCorsPolicy active (app.UseCors in pipeline); dev origins http://localhost:4200 / https://localhost:4200 in Cors:AllowedOrigins |
Base: /api/services
| Endpoint | Response |
|---|---|
GET / |
ServiceStatusDto[] |
GET /{name} |
ServiceStatusDto, or 404 ErrorDto |
POST /{name}/start, POST /{name}/stop |
Blocks until job ends (timeout 600 s) → 200 JobResultDto / 500 JobFailed / 504 JobTimeout / 403·404·409·500·502 ErrorDto |
POST /start-all, POST /stop-all |
Fail-fast sequence → 200 SequenceSuccessDto / 500·504 SequenceFailureDto |
POST /{name}/monitor-start, POST /{name}/monitor-stop |
SSE stream (text/event-stream) |
ServiceStatusDto { name, description, loadState, activeState, subState,
unitFileState, fragmentPath, jobId?: number|null,
jobPath?: string|null, stateChangedUtc?: string|null /* ISO "O" */ }
JobResultDto { jobId, jobPath, result }
ErrorDto { error, errorName, message }
SequenceSuccessDto { completed: string[] }
SequenceFailureDto { failedService, result?: string|null, completed: string[] }Note: SequenceFailureDto.result may be a systemd job result or a D-Bus error name
(e.g. org.freedesktop.systemd1.UnitMasked) — tone mappers need an unknown-value fallback.
SSE events (one JSON data object each, stream closes after last frame):
job-enqueued { unit, jobId, jobPath }
state-changed { loadState?, activeState?, subState?, unitFileState?, jobId? } // * times
job-completed { jobId, result }
error { message }
Known quirk (documented in backend): a state-changed frame may legitimately precede job-enqueued (systemd can emit the first PropertiesChanged before the method reply). job-completed is always last.
Status semantics: job results done = success; failed, canceled, timeout,
dependency, skipped = not success.
Unit states (systemd): active/inactive/activating/deactivating/failed/unknown.
- Angular 22 (CLI 22.1.8, Node 24),
ng new: scss, no SSR, zoneless, Jasmine,--skip-git. frontend/is non-empty (.git, swagger,.opencode) → scaffold into a temp dir, move contents intofrontend/, preserve git repo and the swagger file.- Add
@angular/material+@angular/cdk(Angular-22-matching versions) + Material Icons. src/app/core/env.ts:API_BASE = 'http://localhost:5193/api'.- Pin dev server port to 4200 (
ng serve --port 4200); backend CORS origins depend on it.
- Angular 22's
ng newdefaults to vitest; used--test-runner=karmato keep the Jasmine decision. ng new --directoryinterpreted the path relative to CWD (scaffold landed infrontend/tmp/...); moved in place, no harm done.- npm 11.19 gates package install scripts: approved
esbuild,lmdb,msgpackr-extract,@parcel/watchervianpm install-scripts approve <pkg>— required for the Angular builder to work. - Installed:
@angular/material@22.1.6,@angular/cdk@22.1.6,material-icons@1.13.14(font@imported insrc/styles.scss, verified bundled intodist). - Port 4200 pinned in
angular.jsonserve options and thenpm startscript (ng serve --port 4200); verified live (HTTP 200 onlocalhost:4200). ng build(production) passes.- Committed as root commit
6882ba1.
src/app/
core/
env.ts
api/
types.ts ServiceStatus, JobResult, SequenceSuccess, SequenceFailure,
ErrorDto, SseJobEnqueued/StateChanged/JobCompleted/Error
services-api.ts injectable: list(), get(name), start(name), stop(name),
startAll(), stopAll(); typed error extraction (ErrorDto)
status.ts pure mappers:
unitStateTone(status) → 'success'|'danger'|'warning'|'neutral'|'progress'
jobResultTone(result) → 'success'|'danger'|'warning'; unknown values → 'warning'
(D-Bus error names in SequenceFailureDto.result fall here)
theme/ M3 dark theme (default) + light, class-based toggle (document.documentElement)
features/
dashboard/
dashboard.component toolbar + card grid + loading/empty/error states
service-card.component
app.component shell
Toolbar: app title · theme toggle icon · Refresh · Start All · Stop All.
Card contents:
- Service name + description (secondary line)
- Status badge with tone:
active→success,failed→danger,activating/deactivating→progress (spinner + pulsing card border),inactive→neutral, other→neutral/warning; text showsactiveState/subState jobIdpresent → "Job #n" chip + spinner, Start/Stop disabled- Last changed: relative time from
stateChangedUtc(displayed in local time) - Actions: Start / Stop (Material buttons, icon+label)
Behavior:
- List via
resource()from@angular/core; re-fetch on Refresh and after every action completes. ErrorDto→ MatSnackBar withmessage(errorNameas context).- Service names: list
namevalues carry the.servicesuffix (e.g.example-sleep.service) — use the returnednameverbatim in all start/stop/monitor calls; never strip or re-append the suffix.
resource()API changed in Angular 22: the Observable-basedrequestoption is gone;resource()now takes a Promise/streamloader. For HttpClient observables the idiomatic form isrxResource()from@angular/core/rxjs-interop(stream: () => api.list()). Deviation from the plan's "resource()from@angular/core" wording — same resource API, RxJS flavor. Ref API in 22.1:.value()/.hasValue()/.isLoading()/.error()/.reload().- Material 22 theming: new API (
mat.define-theme/mat.theme, M2 palettes likemat.$blue-palette). Components now bundle their own structural CSS; the global stylesheet only emits system tokens viamat.theme(). Dark/light is a single theme withtheme-type: color-scheme— every color token compiles tolight-dark(light, dark)and followscolor-schemeon<html>(dark by default,html.theme-light { color-scheme: light; }flips it).ThemeServicetogglestheme-dark/theme-lightclasses ondocument.documentElement, persists tolocalStorage['sm-theme'];index.htmlsets the class pre-paint to avoid a flash. - App-level tone colors live in
:rootas--sm-tone-*(alsolight-dark()). - Singletons use the v22
@Service()decorator; cards useinput()/output(). - Shell stayed as generated
app.ts(2025 naming) rendering<app-dashboard />;provideHttpClient()added toapp.config.ts. - Bundle: initial ~454 kB raw / ~106 kB transfer (under the 500 kB warning budget).
- Verified: production build, karma test (1/1), and a live headless-Chrome DOM dump against
the running backend — card renders with
badge--neutral"inactive / dead", Start/Stop buttons, dark theme default. - Rework (user feedback):
service-cardinitially used plain<div>s insidemat-card—mat-cardadds no padding of its own, so the layout was off. Rebuilt onto the predefined sections:mat-card-header(mat-card-title/mat-card-subtitle),mat-card-content(status badge + job/last-changed row),mat-card-actions,appearance="outlined",role="group". Process fix recorded in AGENTS.md/STATE.md: always consult.opencode/skills/angular-material/references/<component>.mdbefore writingmat-*markup.
src/app/core/sse/monitor-stream.ts:- Endpoints are POST → native
EventSourcecan't be used. fetch(POST)+response.body.getReader(); manual SSE frame parser (buffer until\n\n, parseevent:/data:lines,JSON.parsepayload).- Exposes
AsyncIterable<SseEvent>(or push to a signal); AbortController to disconnect. Disconnecting does NOT cancel the job — it continues server-side (state this in UI copy).
- Endpoints are POST → native
features/job-monitor/monitor-panel.component(side drawer):- Header: service name + "starting…/stopping…"
- Live state line per
state-changed(tone-colored) - Collapsible event log (timestamped frames)
- Terminal state:
job-completed→ greendone/ redfailed/ amber others;errorframe → red + message - Must tolerate
state-changedbeforejob-enqueued; timeout arrives as anerrorevent ("Timed out after Ns…"), not a distinct frame - On close → re-fetch service list
- Wiring: card Start/Stop → open monitor → stream → close on completion.
- Transport (
core/sse/monitor-stream.ts): POST SSE viafetch+response.body.getReader()(no nativeEventSource).openMonitorStream(url)returns{ events: AsyncGenerator<SseEvent>, abort(), aborted }; anAbortControllerbacks disconnect.parseSseFrame()is a pure, exported helper (buffer-to-\n\nloop inreadMonitorEventscalls it) so it can be unit-tested with synthetic/partial frames. Non-2xx / no-body responses yield a syntheticerrorframe carrying the backendErrorDto.message(or an HTTP-status fallback), so the panel always has a terminalerrorto render. - Panel (
features/job-monitor/monitor-panel.component): amat-sidenav(position="end",mode="over"— changed from the plan's drawer push so opening the monitor does not reflow the card grid) rendered insidemat-sidenav-container; the dashboard keeps asessionsignal ({service, action}) andviewChild.required<MatSidenav>to open/close. The panel consumes theAsyncIterablein afor awaitloop (started inngAfterViewInit, aborted inngOnDestroy). Signals:status(running/completed/error),result,resultTone,jobId,liveState(tone fromactiveStateTone), and a timestampedlogof every frame. - Terminal handling:
job-completed→ tone viajobResultTone(greendone, redfailed, amber other) and an auto-close timer (2500 ms) so the result is visible before the drawer closes and the list re-fetches.errorframe → red + message, no auto-close (user dismisses). A stream that ends stillrunning(empty body, dropped connection) is coerced to anerrorstate with an explanatory message. - Ordering quirk handled for real: observed a
state-changedframe beforejob-enqueuedand a trailingstate-changedwithjobId:0after completion — the panel tolerates both (state line renders from anystate-changed;jobIdchip only once known). - UI copy: "Closing this panel does not cancel the job — it keeps running in the background." (plan requirement; disconnect ≠ cancel).
- Card actions get a
busyinput; while a monitor session is open, all card Start/Stop and the toolbar Start All / Stop All are disabled (one job monitored at a time). - Deviation: Start All / Stop All keep the existing blocking endpoints + snackbar (the progress-dialog sub-item was dropped from the plan before implementation).
- Bundle: initial grew ~454 kB → 531 kB raw (119 kB transfer), now over the
500 kB warning budget (1 MB error still clear). Cause:
MatSidenavModule+MatExpansionModulepulled in eagerly. Candidate for Phase 4: lazy-load the monitor feature (loadComponent/@defer) if the warning matters. - Verified live (backend :5193, 1 s artificial SSE delay,
Restart=no): startexample-sleep-2→ liveinactive/dead→active/running→ greendone, drawer auto-closes, card flips toactive; stopexample-sleep-3→deactivating/stop-sigterm→inactive/dead→done; start already-activeexample-sleep→done(observed state-changed-before-enqueued); start missingexample-missing→ empty stream → red error state. Production build + karma pass.
- Loading skeletons, empty state, backend-down error state.
- Accessibility: aria-live on status changes, button labels, focus states.
- Jasmine + TestBed tests:
status.tstone mappers (all states + all job results)- SSE parser (feed synthetic frames incl. ordering quirk, partial chunks)
services-apierror mapping (ErrorDto from non-2xx)service-cardandmonitor-panelwith Material test harnesses
ng lint,ng test,ng build.- Live pass against backend: list renders → start → live stream → green
done→ stop → start-all sequence → failure coloring (e.g. start an already-active service →failed→ red). README.mdinfrontend/: run steps, CORS note (dev origins configured in backendCors:AllowedOrigins; production needs the production origin added there or same-origin serving of the built app), production serving note.
- Closed as-is on 2026-09-20 (explicit user decision; no new code in this phase).
Loading/empty/error+Retry states and the a11y basics (aria-live, button labels,
sidenav focus capture in
overmode) had already landed incrementally in Phases 2–3. - Live verification was done incrementally instead of as one pass: the Phase 3 pass
(start → live stream → green
done, stop, missing unit, already-active start) plus the CDP pass inf3f4abf(backdrop click / Esc / in-panel Close all clear the monitor session, re-enable the buttons and re-fetch the list). - Deferred as known debt (listed in STATE.md "Known debt"): unit tests beyond the
smoke test, lint setup (
ng add @angular-eslint),README.md, the Start All / Stop All failure live check, bundle lazy-load of the monitor panel, Start All/Stop All in-button spinner, periodic "Changed Xm ago" tick, loading skeleton.
- CORS: resolved —
app.UseCors("CustomCorsPolicy")added to the backend pipeline with real dev origins; verified live (preflight + GET return correctAccess-Control-Allow-Originheaders). - Job timeout is 600 s: single-service ops are comfortable via the monitor; the blocking sequence endpoints are the one place a request can sit up to 10 min.
- The
angular-cliMCPlist_projectstool currently returns a schema error (MCP bug) — non-blocking; drivengCLI directly and use local Angular skills (.opencode/skills/angular-developer,angular-material). - Backend internals are out of scope; swagger + DTOs are sufficient for the frontend.