9.0 KiB
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
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— oneNodeMasterApiClientclass, 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 typedApiExceptionviadio_error_mapper.dart. This is the only place that touchesDio/DioExceptiondirectly.lib/data/repositories/*_repository.dart— one repository per REST resource group (NodeRepository,ServicesRepository,NodesRepository), each a thin pass-through overNodeMasterApiClient. Each repository has a paired@riverpodprovider (e.g.nodeRepositoryProvider) that returns null when there's no active connection — this null-propagation pattern is used consistently up throughapiClientProviderso 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, awidgets/subfolder for feature-local dialogs/rows, and aproviders/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 ThemeExtensions — 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
ApiExceptionand reads.userMessagefor display; it should never need to inspectDioExceptionor 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).