Files
2026-07-27 14:42:23 +02:00

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/networkdata/repositoriesfeatures/*/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 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 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).