162 lines
9.0 KiB
Markdown
162 lines
9.0 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## What this is
|
|
|
|
NodeMaster Manager — a Flutter GUI client for the NodeMaster API, a Go/Beego
|
|
backend that lives in the sibling repo `../nodemaster` (i.e.
|
|
`/home/yves/Documents/code/PROJECTS/nodemaster/nodemaster` if this repo is
|
|
checked out at `/home/yves/Documents/code/PROJECTS/nodemaster/manager`).
|
|
NodeMaster manages a homelab-style host: docker-compose "services" (start/
|
|
stop/backup), restic/rsync backup targets, OS update history, and a fleet of
|
|
other NodeMaster nodes. This app is the multi-platform (Linux/macOS/Windows/
|
|
web/Android/iOS) desktop-first UI over that API's REST endpoints under `/v1`.
|
|
|
|
**The Go API is the source of truth for wire shapes.** Every Dart model in
|
|
`lib/core/models/` mirrors a Go struct in `../nodemaster/models/`, field for
|
|
field, including `@JsonKey` renames for snake_case. When the API changes,
|
|
re-read the actual Go source (`models/*.go`, `controllers/*.go`) rather than
|
|
trusting existing Dart models or prior assumptions — they can drift, and
|
|
swagger docs in that repo have been observed stale relative to the
|
|
controllers. Neither repo currently has authentication; the API is meant to
|
|
sit behind a VPN/reverse proxy (see the `NoAuthCallout` banner on Settings).
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
flutter pub get # install deps
|
|
dart run build_runner build --delete-conflicting-outputs # regenerate *.freezed.dart / *.g.dart
|
|
dart run build_runner watch --delete-conflicting-outputs # regenerate on save, for long edit sessions
|
|
flutter analyze # static analysis (must be clean)
|
|
flutter test # full test suite
|
|
flutter test test/core/models/service_test.dart # single test file
|
|
flutter test --plain-name "parses a fully-populated service" # single test by name
|
|
flutter run -d chrome # run as web app
|
|
flutter run -d linux # run as Linux desktop app
|
|
flutter build linux --debug # build the Linux bundle (build/linux/x64/debug/bundle/manager)
|
|
```
|
|
|
|
**Any change to a `@freezed` or `@riverpod`/`@Riverpod` class requires a
|
|
`build_runner build` afterward** — the `.freezed.dart`/`.g.dart` companion
|
|
files are committed generated code, not build artifacts recreated from
|
|
scratch each time, so a source edit without regeneration leaves the app
|
|
compiling against stale generated code (or plain compile errors if fields
|
|
changed). If a diagnostics pass immediately after a `build_runner` run shows
|
|
errors referencing symbols you didn't touch, it's very likely IDE/analyzer
|
|
staleness from the file swap — rerun `flutter analyze` fresh before
|
|
concluding something is actually broken.
|
|
|
|
To run the app against a live API, start the sibling Go repo first
|
|
(`cd ../nodemaster && go run main.go`, default port `8080`, config at
|
|
`~/.nodemaster.conf`), then add a connection in Settings pointing at it (or a
|
|
pre-seeded `SharedPreferences` `connections` value in tests — see
|
|
`test/widget_test.dart`).
|
|
|
|
## Architecture
|
|
|
|
### Layering: `core/network` → `data/repositories` → `features/*/providers` → screens
|
|
|
|
- **`lib/core/network/node_master_api_client.dart`** — one `NodeMasterApiClient`
|
|
class, one method per REST endpoint, thin (dio call in, typed model out).
|
|
Every method funnels through `_guard`, which enforces a hard 20s request
|
|
budget (`Future.timeout`, not just Dio's own timeouts — needed because
|
|
Dio's web/browser adapter has no connect-phase timeout hook) and maps every
|
|
failure mode to a typed `ApiException` via `dio_error_mapper.dart`. This is
|
|
the *only* place that touches `Dio`/`DioException` directly.
|
|
- **`lib/data/repositories/*_repository.dart`** — one repository per REST
|
|
resource group (`NodeRepository`, `ServicesRepository`, `NodesRepository`),
|
|
each a thin pass-through over `NodeMasterApiClient`. Each repository has a
|
|
paired `@riverpod` provider (e.g. `nodeRepositoryProvider`) that returns
|
|
**null when there's no active connection** — this null-propagation pattern
|
|
is used consistently up through `apiClientProvider` so every downstream
|
|
provider gets the same "no connection" signal without re-deriving it.
|
|
- **`lib/features/<name>/`** — one folder per nav destination (`dashboard`,
|
|
`services`, `backups`, `fleet`, `updates`, `settings`), each with a
|
|
top-level `<name>_screen.dart`, a `widgets/` subfolder for
|
|
feature-local dialogs/rows, and a `providers/` subfolder where the screen
|
|
needs bespoke async state beyond a bare repository call.
|
|
|
|
### State management: Riverpod 3 with code generation
|
|
|
|
All providers use `@riverpod`/`@Riverpod(...)` annotations + generated `.g.dart`
|
|
companions (not the older manual `Provider`/`StateNotifierProvider` syntax).
|
|
`main.dart` disables Riverpod 3's container-wide auto-retry
|
|
(`retry: (retryCount, error) => null`) because the app already has its own
|
|
explicit refresh model (manual pull-to-refresh + `PollingMixin` for two
|
|
screens) — a blanket retry-with-backoff would silently fight that. Widget
|
|
tests must pass the same `retry: null` override or a deliberately-unreachable
|
|
test connection won't settle into `AsyncError` deterministically.
|
|
|
|
`lib/core/polling/polling_async_notifier.dart`'s `PollingMixin<T>` is mixed
|
|
into an `AutoDisposeAsyncNotifier` to re-fetch on a timer while the provider
|
|
has a listener (used by Dashboard's summary and Fleet's aggregated view —
|
|
the API has no push/websocket, so this is a deliberate "poll while visible"
|
|
model, not a live stream). It never emits an intermediate `AsyncLoading` on
|
|
refresh ticks, so a background poll never flashes the UI back to a spinner.
|
|
Other list-backed screens (Services, node backup config, updates) are
|
|
manual-refresh only, on purpose — a silent background refresh could yank
|
|
state out from under an in-progress edit dialog.
|
|
|
|
### Connections (multi-host support)
|
|
|
|
`lib/core/connections/` models a list of saved API endpoints
|
|
(`Connection { id, name, baseUrl }`), persisted as plain JSON in
|
|
`SharedPreferences` via `ConnectionStorage` (explicitly *not* a secret store
|
|
— the API has no auth, so a saved base URL isn't sensitive by itself).
|
|
`activeConnectionProvider` (in `connection_providers.dart`) is the single
|
|
source everything downstream derives from: `apiClientProvider` rebuilds
|
|
(never mutates) a fresh `Dio`/`NodeMasterApiClient` whenever it changes, which
|
|
cascades through every repository and feature provider automatically.
|
|
|
|
### Routing
|
|
|
|
`lib/core/routing/app_router.dart` uses `go_router` with a single
|
|
`@Riverpod(keepAlive: true)` `GoRouter`. A `_ConnectionRefreshListenable`
|
|
bridges `activeConnectionProvider` into go_router's `refreshListenable` so
|
|
removing the active connection redirects to Settings immediately rather than
|
|
waiting for the next navigation. All feature screens sit inside one
|
|
`ShellRoute` wrapping `NavRailShell` (the persistent side nav).
|
|
|
|
### Models (`lib/core/models/`)
|
|
|
|
`@freezed` sealed classes + `json_serializable` (`explicit_to_json: true` in
|
|
`build.yaml`), one file per Go struct, with `part 'x.freezed.dart'` /
|
|
`part 'x.g.dart'`. Field renames to match the API's snake_case use
|
|
`@JsonKey(name: '...')`. Write-only fields the API never echoes back (e.g. a
|
|
password) should default to `null`/omitted-on-null (`includeIfNull: false`)
|
|
rather than round-tripping a value the server will never actually send.
|
|
`lib/core/models/models.dart` is the barrel export — import that, not
|
|
individual model files, from feature code.
|
|
|
|
### Theming
|
|
|
|
`lib/core/theme/` defines two custom `ThemeExtension`s — `StatusColors`
|
|
(good/warning/serious/critical, reserved exclusively for status semantics,
|
|
never reused as a brand color) and `AppDataStyles` (monospace styles for
|
|
numeric/data display). Access via the `BuildContext` extension in
|
|
`theme_x.dart`: `context.status.good`, `context.dataStyles.dataMono`,
|
|
`context.colors`, `context.text` — prefer these over raw
|
|
`Theme.of(context).extension<...>()!` calls. `StatusPill` (`core/widgets/`)
|
|
is the shared good/warning/serious/critical/neutral badge — reuse it rather
|
|
than building ad hoc colored chips.
|
|
|
|
### Error handling
|
|
|
|
`ApiException` (`core/network/api_exception.dart`) is a sealed/freezed-style
|
|
type with variants for timeout, network, cancelled, server (with status code
|
|
+ message), parse, and unknown. UI code catches `ApiException` and reads
|
|
`.userMessage` for display; it should never need to inspect `DioException` or
|
|
raw status codes directly.
|
|
|
|
## Testing conventions
|
|
|
|
Widget tests spin up a real `ProviderScope` with `retry: null` and
|
|
`SharedPreferences.setMockInitialValues(...)` to seed connection state (see
|
|
`test/widget_test.dart`) rather than mocking providers individually — this
|
|
exercises the actual router-redirect and provider-null-propagation logic. A
|
|
deliberately unreachable connection (`http://127.0.0.1:1`) is used to get a
|
|
fast, deterministic connection-refused error without depending on real
|
|
network access. `mocktail`/`fake_async` are available as dev dependencies for
|
|
narrower unit tests (e.g. `dio_error_mapper_test.dart`).
|