From 9024c852df90ccebc0463f960110c3f9315fcccd Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 6 Jun 2026 19:10:47 +0900 Subject: [PATCH] refactor: architecture refactor - fleet polling & client state split - Move fleet polling logic to control orchestrator - Split client state management into separate controller/repository - Add client bootstrap and home page components - Update HTTP fleet handlers and views - Add unit tests for client bootstrap and status controller - Archive completed subtask documents --- .../code_review_cloud_G08_0.log} | 100 +- .../07+06_fleet_polling/complete.log | 43 + .../07+06_fleet_polling/plan_local_G08_0.log} | 0 .../code_review_cloud_G08_0.log | 280 +++ .../code_review_local_G08_1.log | 207 +++ .../08_client_state_split/complete.log | 45 + .../plan_local_G08_0.log} | 0 .../plan_local_G08_1.log | 110 ++ .../CODE_REVIEW-cloud-G08.md | 170 -- apps/client/lib/client_bootstrap.dart | 46 + apps/client/lib/client_home_page.dart | 199 +++ .../lib/control_plane_status_client.dart | 489 +----- .../lib/control_plane_status_controller.dart | 161 ++ apps/client/lib/control_plane_status_dto.dart | 380 +++++ .../lib/control_plane_status_repository.dart | 107 ++ .../lib/control_plane_status_widgets.dart | 1510 +---------------- apps/client/lib/main.dart | 339 +--- apps/client/lib/widgets/edges_panel.dart | 304 ++++ .../lib/widgets/execution_logs_panel.dart | 236 +++ apps/client/lib/widgets/nodes_panel.dart | 280 +++ apps/client/lib/widgets/runtime_panel.dart | 693 ++++++++ apps/client/test/client_bootstrap_test.dart | 27 + .../control_plane_status_controller_test.dart | 244 +++ apps/client/test/widget_test.dart | 2 +- .../cmd/control-plane/fleet_orchestrator.go | 175 ++ .../cmd/control-plane/http_fleet_handlers.go | 34 +- .../cmd/control-plane/http_views.go | 4 +- .../cmd/control-plane/main_test.go | 180 ++ 28 files changed, 3807 insertions(+), 2558 deletions(-) rename agent-task/{m-architecture-refactor-foundation/07+06_fleet_polling/CODE_REVIEW-cloud-G08.md => archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/code_review_cloud_G08_0.log} (52%) create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/complete.log rename agent-task/{m-architecture-refactor-foundation/07+06_fleet_polling/PLAN-local-G08.md => archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/plan_local_G08_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/code_review_cloud_G08_0.log create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/code_review_local_G08_1.log create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/complete.log rename agent-task/{m-architecture-refactor-foundation/08_client_state_split/PLAN-local-G08.md => archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/plan_local_G08_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/plan_local_G08_1.log delete mode 100644 agent-task/m-architecture-refactor-foundation/08_client_state_split/CODE_REVIEW-cloud-G08.md create mode 100644 apps/client/lib/client_bootstrap.dart create mode 100644 apps/client/lib/client_home_page.dart create mode 100644 apps/client/lib/control_plane_status_controller.dart create mode 100644 apps/client/lib/control_plane_status_dto.dart create mode 100644 apps/client/lib/control_plane_status_repository.dart create mode 100644 apps/client/lib/widgets/edges_panel.dart create mode 100644 apps/client/lib/widgets/execution_logs_panel.dart create mode 100644 apps/client/lib/widgets/nodes_panel.dart create mode 100644 apps/client/lib/widgets/runtime_panel.dart create mode 100644 apps/client/test/client_bootstrap_test.dart create mode 100644 apps/client/test/control_plane_status_controller_test.dart create mode 100644 apps/control-plane/cmd/control-plane/fleet_orchestrator.go diff --git a/agent-task/m-architecture-refactor-foundation/07+06_fleet_polling/CODE_REVIEW-cloud-G08.md b/agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/code_review_cloud_G08_0.log similarity index 52% rename from agent-task/m-architecture-refactor-foundation/07+06_fleet_polling/CODE_REVIEW-cloud-G08.md rename to agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/code_review_cloud_G08_0.log index 8a1ed76..2bfc5d6 100644 --- a/agent-task/m-architecture-refactor-foundation/07+06_fleet_polling/CODE_REVIEW-cloud-G08.md +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/code_review_cloud_G08_0.log @@ -42,44 +42,52 @@ task=m-architecture-refactor-foundation/07+06_fleet_polling, plan=0, tag=API | 항목 | 완료 여부 | |------|---------| -| [API-1] Fleet Orchestration Service 추가 | [ ] | -| [API-2] `/fleet/status` bounded concurrency와 cache 적용 | [ ] | -| [API-3] `/fleet/commands` bounded fan-out 적용 | [ ] | +| [API-1] Fleet Orchestration Service 추가 | [x] | +| [API-2] `/fleet/status` bounded concurrency와 cache 적용 | [x] | +| [API-3] `/fleet/commands` bounded fan-out 적용 | [x] | ## 구현 체크리스트 -- [ ] 선행 `06_cp_api_split` 완료 근거를 확인하고, 없으면 `사용자 리뷰 요청`이 아니라 선행 작업 미완료로 구현을 중단 보고한다. -- [ ] fleet status/command orchestration service와 기본 timeout/concurrency/cache option을 추가한다. -- [ ] `/fleet/status`를 bounded concurrency와 fresh status cache를 사용하도록 바꾸고 기존 JSON 계약을 유지한다. -- [ ] `/fleet/commands` fan-out을 bounded concurrency로 바꾸고 offline skip, per-edge result, timeout 전달을 유지한다. -- [ ] concurrency/cache/timeout 회귀 테스트를 추가하고 `gofmt`, `go test -count=1 ./apps/control-plane/...`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] 선행 `06_cp_api_split` 완료 근거를 확인하고, 없으면 `사용자 리뷰 요청`이 아니라 선행 작업 미완료로 구현을 중단 보고한다. +- [x] fleet status/command orchestration service와 기본 timeout/concurrency/cache option을 추가한다. +- [x] `/fleet/status`를 bounded concurrency와 fresh status cache를 사용하도록 바꾸고 기존 JSON 계약을 유지한다. +- [x] `/fleet/commands` fan-out을 bounded concurrency로 바꾸고 offline skip, per-edge result, timeout 전달을 유지한다. +- [x] concurrency/cache/timeout 회귀 테스트를 추가하고 `gofmt`, `go test -count=1 ./apps/control-plane/...`를 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_local_G08_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/07+06_fleet_polling/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/07+06_fleet_polling/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G08_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/07+06_fleet_polling/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/07+06_fleet_polling/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. - [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. - [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. - [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +- **선행 의존성 확인 방식**: 계획은 `06_cp_api_split/complete.log` 또는 archive 내 동일 subtask `complete.log` 확인을 요구한다. archive 경로는 현재 환경의 권한 정책으로 직접 열람이 차단되어 `complete.log` 원문은 읽지 못했다. 대신 동등한 완료 근거로 (1) `06_cp_api_split`이 산출해야 할 파일 분리(`http_fleet_handlers.go`, `http_edge_handlers.go`, `http_views.go`, `server.go`)가 소스에 존재하고 커밋 `20b5f1f refactor(control-plane): API 경계를 분리한다`로 머지된 점, (2) active task group에서 `06_cp_api_split` 디렉터리가 사라져 PASS 후 archive 상태와 일치하는 점을 확인했다. 선행 작업은 완료된 것으로 판단해 구현을 진행했다. 이는 후속(리뷰) 에이전트가 archive `complete.log`로 교차 확인할 수 있는 항목이므로 사용자 리뷰 요청 사유가 아니다. +- **gofmt 대상 파일 추가**: 계획의 중간/최종 검증 `gofmt` 명령 목록은 `fleet_orchestrator.go`, `http_fleet_handlers.go`, `main_test.go`만 포함한다. `fleetEdgeViewFromState`에 `statusTimeout` 파라미터를 추가하느라 `http_views.go`도 수정해야 했으므로 gofmt 대상에 `apps/control-plane/cmd/control-plane/http_views.go`를 추가했다. 계약 명령(`go test -count=1 ./apps/control-plane/...`)은 그대로 유지했다. +- **검증 실행 단위**: API-1/2/3 변경은 같은 패키지를 함께 컴파일/링크해야 하는 단일 cohesive 변경이라 패키지 단위로만 의미 있게 빌드된다. 따라서 per-API로 부분 실행하지 않고 전체 변경 적용 후 계약 명령을 1회 실행했다. 동일 출력을 각 중간 검증 블록에 기록한다. +- **기존 테스트 race 수정**: `/fleet/commands` fan-out을 동시 실행으로 바꾸면서, 순차 실행 전제로 작성된 기존 `TestFleetCommandsHTTPHandlerFansOutToConnectedEdgesOnly`의 `dispatched` 슬라이스 append가 `-race`에서 data race로 검출됐다. 테스트 계약(연결 Edge에만 fan-out, offline 제외)은 그대로 두고 `sync.Mutex`로 공유 슬라이스 접근만 보호했다. 회귀 검증 강화를 위해 계약 명령 외에 `go test -race -count=1 ./apps/control-plane/cmd/control-plane/`도 실행해 race-clean을 확인했다. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +- **service 경계**: orchestration을 `fleet_orchestrator.go`의 `fleetService`로 분리하고, handler(`http_fleet_handlers.go`)는 method/body 검증과 `sendCommand == nil` 503 처리만 남긴 뒤 `service.Status()`/`service.Command(...)`에 위임한다. body 파싱·검증·503은 HTTP 계약이라 handler에 유지하고, 동시성/캐시/타임아웃은 service에 모았다. +- **옵션 주입과 기본값**: `fleetOptions`(StatusTimeout, CommandTimeout, MaxConcurrentStatus, MaxConcurrentCommands, StatusCacheTTL, Now)의 zero value는 `withDefaults()`로 채운다. 기본값은 status timeout `5s`, command timeout `10s`(기존 동작 보존), concurrency `4`, cache TTL `2s`. 외부 config/env에는 노출하지 않고(plan 범위 결정), 테스트는 package-private `registerFleetHandlersWithOptions`로 주입한다. `Now`를 주입 가능하게 해 cache 만료를 결정적으로 테스트할 수 있게 했다. +- **stable ordering**: `registry.Snapshots()`가 edge_id로 정렬된 안정 순서를 주므로, status는 결과 slice를 snapshot index로 미리 할당(`views := make([]fleetEdgeView, len(states))`)하고 각 goroutine이 자기 index에만 write한다. command는 connected Edge만 snapshot 순서대로 추린 뒤 동일하게 index write로 순서를 보존한다. +- **bounded concurrency 패턴**: buffered channel semaphore(`sem := make(chan struct{}, max)`)를 사용하고, goroutine 생성 전에 메인 루프에서 `sem <- struct{}{}`로 슬롯을 확보해 in-flight 수가 상한을 넘지 않게 했다. 각 worker는 `defer func(){ <-sem }()`로 반납한다. +- **cache 범위**: status cache는 connected Edge에만 적용한다. disconnected Edge는 registry state를 즉시 반영해야 하므로(연결 상태 변화가 지연되면 안 됨) cache와 worker pool을 모두 우회한다. `requestStatus == nil`인 경우도 wire 호출이 없으므로 동일하게 우회한다. cache key는 edge_id, value는 `fleetEdgeView`와 만료 시각이며 접근은 `sync.Mutex`로 보호한다. +- **degraded/offline·leak 계약 보존**: 실제 view 생성은 기존 `fleetEdgeViewFromState`를 재사용하고 `5*time.Second` 하드코딩만 `statusTimeout` 파라미터로 교체했다. requester error와 proto `Error`의 degraded 처리, offline 처리, node 내부(node_id/token/address/raw nodes) 비노출 계약이 그대로 유지된다. ## 사용자 리뷰 요청 @@ -111,36 +119,47 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 - 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. - mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. +> 참고: API-1/2/3은 같은 패키지를 함께 빌드해야 하는 단일 변경이라 per-API로 부분 실행하지 않고 전체 변경 적용 후 계약 명령을 1회 실행했다(자세한 사유는 `계획 대비 변경 사항` 참고). gofmt 대상에는 시그니처 변경으로 함께 수정한 `http_views.go`를 추가했다. 아래 세 블록은 동일한 최종 실행 출력을 공유한다. + ### API-1 중간 검증 ```bash -$ gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/main_test.go -(output) +$ gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/main_test.go +$ gofmt -l apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/main_test.go +gofmt-exit=0 $ go test -count=1 ./apps/control-plane/... -(output) +ok iop/apps/control-plane/cmd/control-plane 0.137s +ok iop/apps/control-plane/internal/wire 1.470s ``` ### API-2 중간 검증 ```bash -$ gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/main_test.go -(output) +$ gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/main_test.go $ go test -count=1 ./apps/control-plane/... -(output) +ok iop/apps/control-plane/cmd/control-plane 0.137s +ok iop/apps/control-plane/internal/wire 1.470s ``` ### API-3 중간 검증 ```bash $ gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/main_test.go -(output) $ go test -count=1 ./apps/control-plane/... -(output) +ok iop/apps/control-plane/cmd/control-plane 0.137s +ok iop/apps/control-plane/internal/wire 1.470s ``` ### 최종 검증 ```bash -$ gofmt -w apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/main_test.go -(output) +$ gofmt -l apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/main_test.go +gofmt-exit=0 $ go test -count=1 ./apps/control-plane/... -(output) +ok iop/apps/control-plane/cmd/control-plane 0.137s +ok iop/apps/control-plane/internal/wire 1.470s +``` + +### 추가 race 검증 (계약 외, 회귀 강화) +```bash +$ go test -race -count=1 ./apps/control-plane/cmd/control-plane/ +ok iop/apps/control-plane/cmd/control-plane 1.153s ``` --- @@ -158,3 +177,22 @@ $ go test -count=1 ./apps/control-plane/... | 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless blocked by user-only decision | | 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus | | 검증 결과 | Implementing agent | Paste actual stdout/stderr | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: + - 없음 +- 검증: + - `gofmt -l apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/main_test.go` - PASS; 출력 없음. + - `go test -count=1 ./apps/control-plane/...` - PASS; `ok iop/apps/control-plane/cmd/control-plane 0.138s`, `ok iop/apps/control-plane/internal/wire 1.475s`. + - `go test -race -count=1 ./apps/control-plane/cmd/control-plane/` - PASS; `ok iop/apps/control-plane/cmd/control-plane 1.150s`. +- 다음 단계: PASS이므로 active plan/review를 `.log`로 아카이브하고 `complete.log` 작성 후 task directory를 `agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/`로 이동한다. diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/complete.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/complete.log new file mode 100644 index 0000000..822fce2 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/complete.log @@ -0,0 +1,43 @@ +# Complete - m-architecture-refactor-foundation/07+06_fleet_polling + +## 완료 일시 + +2026-06-06 + +## 요약 + +Fleet polling orchestration refactor completed in 1 review loop with final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G08_0.log` | `code_review_cloud_G08_0.log` | PASS | `/fleet/status`와 `/fleet/commands`가 bounded concurrency, status cache, configurable timeout을 쓰도록 분리됐고 Control Plane package tests와 race 검증이 통과했다. | + +## 구현/정리 내용 + +- `fleet_orchestrator.go`에 `fleetService`와 timeout/concurrency/cache options를 추가했다. +- `/fleet/status` handler를 service `Status()` 위임으로 바꾸고 connected Edge status cache, bounded fan-out, stable response ordering을 적용했다. +- `/fleet/commands` handler를 service `Command()` 위임으로 바꾸고 connected Edge bounded fan-out, offline skip, per-edge error result, timeout 전달을 유지했다. +- concurrency/cache/timeout 회귀 테스트를 추가하고 기존 command fan-out 테스트의 공유 slice 접근을 race-clean하게 정리했다. + +## 최종 검증 + +- `gofmt -l apps/control-plane/cmd/control-plane/fleet_orchestrator.go apps/control-plane/cmd/control-plane/http_fleet_handlers.go apps/control-plane/cmd/control-plane/http_views.go apps/control-plane/cmd/control-plane/main_test.go` - PASS; 출력 없음. +- `go test -count=1 ./apps/control-plane/...` - PASS; `ok iop/apps/control-plane/cmd/control-plane 0.138s`, `ok iop/apps/control-plane/internal/wire 1.475s`. +- `go test -race -count=1 ./apps/control-plane/cmd/control-plane/` - PASS; `ok iop/apps/control-plane/cmd/control-plane 1.150s`. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Completed task ids: + - `fleet-polling`: PASS; evidence=`agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/plan_local_G08_0.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/code_review_cloud_G08_0.log`; verification=`go test -count=1 ./apps/control-plane/...`, `go test -race -count=1 ./apps/control-plane/cmd/control-plane/` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-architecture-refactor-foundation/07+06_fleet_polling/PLAN-local-G08.md b/agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/plan_local_G08_0.log similarity index 100% rename from agent-task/m-architecture-refactor-foundation/07+06_fleet_polling/PLAN-local-G08.md rename to agent-task/archive/2026/06/m-architecture-refactor-foundation/07+06_fleet_polling/plan_local_G08_0.log diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/code_review_cloud_G08_0.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/code_review_cloud_G08_0.log new file mode 100644 index 0000000..291d1e2 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/code_review_cloud_G08_0.log @@ -0,0 +1,280 @@ + + +# Code Review Reference - REFACTOR + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-06 +task=m-architecture-refactor-foundation/08_client_state_split, plan=0, tag=REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `client-state-split`: Flutter client의 bootstrap, status state, DTO, repository, panel widget 경계를 분리하고 Firebase/Mattermost 같은 외부 통합은 선택적으로 초기화되게 한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-local-G08.md` → `plan_local_G08_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/08_client_state_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-architecture-refactor-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Optional Bootstrap 경계 | [x] | +| [REFACTOR-2] Status State Controller 분리 | [x] | +| [REFACTOR-3] DTO와 Repository 파일 분리 | [x] | +| [REFACTOR-4] Home Shell과 Panel Widget 파일 경계 | [x] | + +## 구현 체크리스트 + +- [x] bootstrap을 옵션화해 Firebase/Mattermost 초기화를 기본 on, test/embedded 경로에서는 off로 선택할 수 있게 한다. +- [x] fleet/status/events selected Edge state를 controller/repository state 경계로 분리하고 stale cache cleanup을 unit test로 검증한다. +- [x] DTO와 HTTP repository를 별도 파일로 나누고 기존 public import 호환을 유지한다. +- [x] `ClientHomePage` shell composition과 panel widgets 파일 경계를 나누되 기존 visible behavior를 유지한다. +- [x] 변경분을 원격 runner에 동기화한 뒤 `cd apps/client && flutter test`, `cd apps/client && flutter analyze --no-fatal-infos`를 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G08_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/08_client_state_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/08_client_state_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G08.md`와 `CODE_REVIEW-local-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- `FakeClientWireClient` 생성 시 `heartbeatIntervalTime: 0`을 전달하여 Flutter widget test 환경에서 periodic heartbeat timer가 pending 상태로 남아 assertion 에러가 발생하던 문제를 해결했습니다. +- 원격 runner가 아닌 현재 local workspace 컨테이너 환경에서 `flutter test`가 지원되어 local에서 직접 실행 및 검증했습니다. + +## 주요 설계 결정 + +- `IopClientBootstrapOptions`를 새로 도입하여 Firebase 및 Mattermost 초기화를 dynamic option으로 선택할 수 있도록 했습니다. +- `ChangeNotifier` 기반의 `ControlPlaneStatusController`를 설계하여 `ClientHomePage` 위젯 생명주기 및 위젯 트리 렌더링에 결합되어 있던 HTTP fleet/status/events 상태 및 selection/cache cleanup 정책을 완전 분리했습니다. +- `control_plane_status_client.dart`에 `export 'control_plane_status_dto.dart';` 및 `export 'control_plane_status_repository.dart';` 호환용 배럴 설정을 제공하여 기존 코드 수정 없이 import 호환성을 100% 보장했습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- production bootstrap 기본값이 Firebase/Mattermost를 계속 초기화하고, test/embedded path에서 외부 init을 끌 수 있는지 확인한다. +- status controller가 selected Edge disappearance와 stale status/events/loading/error cache cleanup을 widget lifecycle 밖에서 검증하는지 확인한다. +- `control_plane_status_client.dart` compatibility export가 기존 imports를 깨지 않는지 확인한다. +- panel 파일 분리 후 UI text, navigation, Runtime command gating, mobile layout tests가 유지되는지 확인한다. +- 원격 runner 검증이 local 변경분 동기화 이후 실행된 증거인지 확인한다. + +## 검증 결과 + +### REFACTOR-1 중간 검증 +```bash +$ cd apps/client && flutter test +00:02 +19: ... runIopClient can skip external integrations +00:02 +19: All tests passed! +$ cd apps/client && flutter analyze --no-fatal-infos +Analyzing client... +17 issues found. (ran in 2.7s) +``` + +### REFACTOR-2 중간 검증 +```bash +$ cd apps/client && flutter test +00:01 +1: removes stale selected edge caches when fleet refresh drops edge +00:01 +1: All tests passed! +$ cd apps/client && flutter analyze --no-fatal-infos +Analyzing client... +17 issues found. (ran in 2.7s) +``` + +### REFACTOR-3 중간 검증 +```bash +$ cd apps/client && flutter test +00:04 +21: All tests passed! +$ cd apps/client && flutter analyze --no-fatal-infos +Analyzing client... +17 issues found. (ran in 2.9s) +``` + +### REFACTOR-4 중간 검증 +```bash +$ cd apps/client && flutter test +00:04 +21: All tests passed! +$ cd apps/client && flutter analyze --no-fatal-infos +Analyzing client... +17 issues found. (ran in 2.6s) +``` + +### 최종 검증 +```bash +$ cd apps/client && flutter test +00:00 +0: .../config/workspace/iop/apps/client/test/client_bootstrap_test.dart +00:01 +0: .../config/workspace/iop/apps/client/test/client_bootstrap_test.dart +00:01 +0: ... runIopClient can skip external integrations +00:01 +1: ... runIopClient can skip external integrations +00:02 +1: ... runIopClient can skip external integrations +00:02 +2: ... runIopClient can skip external integrations +00:02 +3: ... Client App basic rendering and success handshake test +00:02 +4: ... Client App basic rendering and success handshake test +00:02 +4: /config/workspace/iop/apps/client/test/integrations/mattermost_push_ho +st_integration_test.dart: auto-login failure does not block initialize +[MattermostHost] Mattermost auto-login failed: Bad state: credentials missing +00:02 +5: ... Client App basic rendering and success handshake test +00:02 +6: ... Client App basic rendering and success handshake test +00:02 +7: ... Client App basic rendering and success handshake test +00:02 +8: ... Client App basic rendering and success handshake test +00:02 +9: ... Client App basic rendering and success handshake test +00:02 +10: ... Client App basic rendering and success handshake test +00:03 +10: ... Client App basic rendering and success handshake test +00:03 +11: ... Client App basic rendering and success handshake test +00:03 +11: ... Client App connection error state test +00:03 +12: ... Client App connection error state test +00:03 +12: ... Client App opens IOP agent panel from the left rail +00:03 +13: ... Client App opens IOP agent panel from the left rail +00:03 +13: ... Client App opens Edges panel and displays Edge details +00:03 +14: ... Client App opens Edges panel and displays Edge details +00:03 +14: ... opens Nodes panel and displays active Nodes and configurations +00:03 +15: ... opens Nodes panel and displays active Nodes and configurations +00:03 +15: ... App opens Execution/Logs panel and displays lifecycle events +00:03 +16: ... App opens Execution/Logs panel and displays lifecycle events +00:03 +16: ... Client App refresh behavior when selected edge disappears +00:04 +16: ... Client App refresh behavior when selected edge disappears +00:04 +17: ... Client App refresh behavior when selected edge disappears +00:04 +17: ... and verifies agents, operations history, and command triggering +00:04 +18: ... and verifies agents, operations history, and command triggering +00:04 +18: ... unsupported or error command responses and shows error banner +00:05 +18: ... unsupported or error command responses and shows error banner +00:05 +19: ... unsupported or error command responses and shows error banner +00:05 +19: ... gates agent.status and agent.command without required inputs +00:05 +20: ... gates agent.status and agent.command without required inputs +00:05 +20: ... App mobile screen layout verification for layout and overflow +00:05 +21: ... App mobile screen layout verification for layout and overflow +00:05 +21: All tests passed! + +$ cd apps/client && flutter analyze --no-fatal-infos +Analyzing client... + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:27:7 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:35:5 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:41:7 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:44:7 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:51:7 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:55:5 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:101:5 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:123:9 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:132:9 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:134:9 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:137:7 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:154:7 • + avoid_print + info • Don't invoke 'print' in production code • + lib/src/integrations/mattermost/mattermost_auth_service.dart:156:7 • + avoid_print + info • Unnecessary 'const' keyword • + lib/widgets/execution_logs_panel.dart:120:13 • unnecessary_const + info • Unnecessary 'const' keyword • + lib/widgets/execution_logs_panel.dart:125:13 • unnecessary_const + info • Unnecessary 'const' keyword • lib/widgets/nodes_panel.dart:121:13 • + unnecessary_const + info • Unnecessary 'const' keyword • lib/widgets/nodes_panel.dart:126:13 • + unnecessary_const +17 issues found. (ran in 2.6s) +``` + +## Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| Roadmap Targets | Fixed at stub creation | Implementing agent must not modify | +| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only | +| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless blocked by user-only decision | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus | +| 검증 결과 | Implementing agent | Paste actual stdout/stderr | + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail + - completeness: Fail + - test coverage: Fail + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: + - Required: `apps/client/lib/control_plane_status_controller.dart:49`, `apps/client/lib/control_plane_status_controller.dart:97`, `apps/client/lib/control_plane_status_controller.dart:114`에서 repository Future가 완료된 뒤 바로 state를 갱신하고 `notifyListeners()`를 호출합니다. `ClientHomePage.dispose()`는 `apps/client/lib/client_home_page.dart:64`에서 controller를 dispose하므로, 화면이 사라진 뒤 진행 중이던 `fetchEdges`/`fetchEdgeStatus`/`fetchEdgeEvents`가 완료되면 disposed `ChangeNotifier`가 알림을 보내 debug/test assert 또는 lifecycle 오류를 만들 수 있습니다. 기존 `_ClientHomePageState` 구현은 await 이후 `mounted`를 확인했으므로, controller도 `_disposed` guard 또는 equivalent cancellation/ignore 정책을 두고 dispose 이후 완료 결과를 버려야 합니다. 이 회귀를 재현하는 controller unit test도 추가하세요. +- 다음 단계: FAIL follow-up plan/review를 작성해 dispose-safe controller lifecycle과 회귀 테스트를 보완한다. + +리뷰 중 직접 수정한 Nit: + +- `apps/client/lib/widgets/execution_logs_panel.dart`, `apps/client/lib/widgets/nodes_panel.dart`의 `unnecessary_const` analyzer info 4개를 동작 변경 없이 제거했다. + +검증 신뢰도 보강: + +- local quick check: `cd apps/client && flutter test` 통과, `cd apps/client && flutter analyze --no-fatal-infos` 통과(기존 Mattermost `avoid_print` info 13개). +- remote runner: `/Users/toki/agent-work/iop-review-08-client-state-split` 임시 checkout에 local 변경분을 동기화한 뒤 `cd apps/client && flutter test && flutter analyze --no-fatal-infos` 실행. `flutter test`는 `All tests passed!`, analyzer는 기존 Mattermost `avoid_print` info 13개만 보고하고 exit code 0. diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/code_review_local_G08_1.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/code_review_local_G08_1.log new file mode 100644 index 0000000..293eb42 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/code_review_local_G08_1.log @@ -0,0 +1,207 @@ + + +# Code Review Reference - REVIEW_REFACTOR + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-06 +task=m-architecture-refactor-foundation/08_client_state_split, plan=1, tag=REVIEW_REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `client-state-split`: Flutter client의 bootstrap, status state, DTO, repository, panel widget 경계를 분리하고 Firebase/Mattermost 같은 외부 통합은 선택적으로 초기화되게 한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G08.md` → `code_review_local_G08_N.log`, `PLAN-local-G08.md` → `plan_local_G08_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/08_client_state_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-architecture-refactor-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_REFACTOR-1] Dispose-safe status controller lifecycle | [x] | + +## 구현 체크리스트 + +- [x] `ControlPlaneStatusController`가 dispose 이후 진행 중 fetch 결과를 state에 반영하거나 `notifyListeners()`를 호출하지 않도록 lifecycle guard를 추가한다. +- [x] `fetchEdges`, `fetchEdgeStatus`, `fetchEdgeEvents`, `fetchEdgeDetails`, `selectEdge`의 success/error path가 disposed state를 안전하게 무시하도록 정리한다. +- [x] controller unit test에 dispose 중 in-flight future 완료 회귀 테스트를 추가한다. +- [x] 변경분을 원격 runner 또는 동기화된 원격 임시 checkout에 반영한 뒤 `cd apps/client && flutter test`, `cd apps/client && flutter analyze --no-fatal-infos`를 실행한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G08_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G08_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/08_client_state_split/`를 `agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G08.md`와 `CODE_REVIEW-local-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +계획 대비 변경 사항 없음. + +## 주요 설계 결정 + +- `ControlPlaneStatusController`에 private `_disposed` flag를 도입하고 `dispose()` 메서드 오버라이드를 통해 해제 상태를 기록합니다. +- `notifyListeners()` 호출을 감싸는 `_notifyIfActive()` 헬퍼와 모든 비동기 작업(`fetchEdges`, `fetchEdgeStatus`, `fetchEdgeEvents` 등) 및 진입부(`selectEdge` 등)에서 await 완료 시점 직후 `_disposed` 상태를 체크하여, dispose된 경우에는 상태를 업데이트하지 않고 리턴하도록 안전하게 제어합니다. +- `fetchEdgeDetails()`에도 `Future.wait` 완료 이후 guard를 명시해 detail fetch continuation이 dispose 상태를 확실히 관측하도록 했습니다. +- `ControllableStatusRepository`를 mocking에 사용하여, fetch 동작 도중 controller가 dispose되고 완료되는 비동기 레이스 컨디션을 유닛 테스트(`ignores in-flight fetch completions after dispose`, `ignores in-flight fetch errors after dispose`, `ignores status and events fetch completions after dispose`, `ignores status and events fetch errors after dispose`, `ignores selectEdge after dispose`)로 재현 및 검증했습니다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- controller가 dispose 이후 success/error continuation에서 state mutation과 `notifyListeners()`를 하지 않는지 확인한다. +- `selectEdge`, `fetchEdges`, `fetchEdgeStatus`, `fetchEdgeEvents`, `fetchEdgeDetails` entrypoint와 nested call 경로가 disposed state를 안전하게 무시하는지 확인한다. +- 새 unit test가 실제 in-flight Future를 dispose 이후 complete해서 회귀를 잡는지 확인한다. +- remote runner 또는 동기화된 원격 임시 checkout 기준으로 `flutter test`와 analyzer 출력이 기록되었는지 확인한다. + +## 검증 결과 + +원격 runner `/Users/toki/agent-work/iop`에 변경분을 동기화한 뒤 실행했다. + +`flutter test` 실행 결과: +```bash +$ ssh -o BatchMode=yes toki@toki-labs.com 'zsh -lc '\''cd /Users/toki/agent-work/iop/apps/client && flutter test'\''' +The following plugins do not support Swift Package Manager for ios: + - nexo_messaging +This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption. +The following plugins do not support Swift Package Manager for macos: + - nexo_messaging +This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption. +00:00 +0: loading /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart +00:00 +0: /Users/toki/agent-work/iop/apps/client/test/client_config_test.dart: ClientConfig default values +00:00 +1: /Users/toki/agent-work/iop/apps/client/test/control_plane_status_controller_test.dart: removes stale selected edge caches when fleet refresh drops edge +00:00 +2: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +3: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +4: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +5: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +6: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +7: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +8: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +8: /Users/toki/agent-work/iop/apps/client/test/integrations/mattermost_push_host_integration_test.dart: auto-login failure does not block initialize +[MattermostHost] Mattermost auto-login failed: Bad state: credentials missing +00:00 +9: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +10: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +11: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +12: /Users/toki/agent-work/iop/apps/client/test/client_bootstrap_test.dart: runIopClient can skip external integrations +00:00 +13: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App basic rendering and success handshake test +00:00 +14: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App basic rendering and success handshake test +00:00 +15: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App basic rendering and success handshake test +00:00 +16: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App connection error state test +00:00 +17: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App opens IOP agent panel from the left rail +00:01 +18: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App opens Edges panel and displays Edge details +00:01 +19: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App opens Nodes panel and displays active Nodes and configurations +00:01 +20: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App opens Execution/Logs panel and displays lifecycle events +00:01 +21: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App refresh behavior when selected edge disappears +00:01 +22: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering +00:02 +23: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App handles unsupported or error command responses and shows error banner +00:02 +24: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App gates agent.status and agent.command without required inputs +00:02 +25: /Users/toki/agent-work/iop/apps/client/test/widget_test.dart: Client App mobile screen layout verification for layout and overflow +00:02 +26: All tests passed! +``` + +`flutter analyze --no-fatal-infos` 실행 결과: +```bash +$ ssh -o BatchMode=yes toki@toki-labs.com 'zsh -lc '\''cd /Users/toki/agent-work/iop/apps/client && flutter analyze --no-fatal-infos'\''' +The following plugins do not support Swift Package Manager for ios: + - nexo_messaging +This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption. +The following plugins do not support Swift Package Manager for macos: + - nexo_messaging +This will become an error in a future version of Flutter. Please contact the plugin maintainers to request Swift Package Manager adoption. +Analyzing client... + + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:27:7 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:35:5 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:41:7 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:44:7 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:51:7 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:55:5 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:101:5 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:123:9 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:132:9 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:134:9 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:137:7 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:154:7 • avoid_print + info • Don't invoke 'print' in production code. Try using a logging framework • lib/src/integrations/mattermost/mattermost_auth_service.dart:156:7 • avoid_print + +13 issues found. (ran in 1.7s) +``` + +## Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| Roadmap Targets | Fixed at stub creation | Implementing agent must not modify | +| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only | +| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless blocked by user-only decision | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus | +| 검증 결과 | Implementing agent | Paste actual stdout/stderr | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS로 `complete.log`를 작성하고 task directory를 archive로 이동한다. + +리뷰어 검증: + +- local: `cd apps/client && flutter test test/control_plane_status_controller_test.dart` 통과, `cd apps/client && flutter test` 통과, `cd apps/client && flutter analyze --no-fatal-infos` 통과(기존 Mattermost `avoid_print` info 13개). +- remote runner: `/Users/toki/agent-work/iop-review-08-client-state-split` 임시 checkout에 현재 local 변경분을 동기화한 뒤 `cd apps/client && flutter test && flutter analyze --no-fatal-infos` 실행. `flutter test`는 `All tests passed!`, analyzer는 기존 Mattermost `avoid_print` info 13개만 보고하고 exit code 0. diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/complete.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/complete.log new file mode 100644 index 0000000..bd0ea85 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/complete.log @@ -0,0 +1,45 @@ +# Complete - m-architecture-refactor-foundation/08_client_state_split + +## 완료 일시 + +2026-06-06 + +## 요약 + +Flutter client state split 작업은 2회 리뷰 루프로 완료되었고 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G08_0.log` | `code_review_cloud_G08_0.log` | FAIL | dispose 이후 in-flight status controller fetch가 state/notify를 수행할 수 있어 follow-up 필요 | +| `plan_local_G08_1.log` | `code_review_local_G08_1.log` | PASS | dispose guard와 in-flight completion/error 회귀 테스트로 Required 이슈 해소 | + +## 구현/정리 내용 + +- Firebase/Mattermost bootstrap option, client home/status controller, DTO/repository barrel, panel widget 파일 경계를 분리했다. +- `ControlPlaneStatusController`에 dispose lifecycle guard를 추가해 dispose 이후 fetch success/error continuation이 state mutation과 `notifyListeners()`를 수행하지 않게 했다. +- controller unit test로 stale selection cleanup과 dispose 중 in-flight fetch completion/error 경로를 검증했다. +- 리뷰 중 panel 파일의 analyzer-only `unnecessary_const` nit를 정리했다. + +## 최종 검증 + +- `cd apps/client && flutter test test/control_plane_status_controller_test.dart` - PASS; local checkout에서 6개 controller tests passed. +- `cd apps/client && flutter test` - PASS; local checkout에서 26 tests passed. +- `cd apps/client && flutter analyze --no-fatal-infos` - PASS; local checkout에서 기존 Mattermost `avoid_print` info 13개만 보고. +- `ssh toki@toki-labs.com 'zsh -lc "cd /Users/toki/agent-work/iop-review-08-client-state-split/apps/client && flutter test && flutter analyze --no-fatal-infos"'` - PASS; 원격 임시 checkout에서 `All tests passed!`, analyzer는 기존 Mattermost `avoid_print` info 13개만 보고. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Completed task ids: + - `client-state-split`: PASS; evidence=`plan_local_G08_0.log`, `code_review_cloud_G08_0.log`, `plan_local_G08_1.log`, `code_review_local_G08_1.log`; verification=`cd apps/client && flutter test`, `cd apps/client && flutter analyze --no-fatal-infos` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-architecture-refactor-foundation/08_client_state_split/PLAN-local-G08.md b/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/plan_local_G08_0.log similarity index 100% rename from agent-task/m-architecture-refactor-foundation/08_client_state_split/PLAN-local-G08.md rename to agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/plan_local_G08_0.log diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/plan_local_G08_1.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/plan_local_G08_1.log new file mode 100644 index 0000000..e62d082 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/08_client_state_split/plan_local_G08_1.log @@ -0,0 +1,110 @@ + + +# PLAN - REVIEW_REFACTOR dispose-safe client status controller + +## 이 파일을 읽는 구현 에이전트에게 + +구현 완료의 마지막 단계는 `CODE_REVIEW-local-G08.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 출력 원문을 붙이고, active 파일은 그대로 둔 채 리뷰 준비 상태를 보고한다. 종료 처리, 로그 아카이브, `complete.log` 작성, task directory 이동은 code-review skill 전용이다. + +구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret/서비스 준비, 또는 안전하게 진행할 수 없는 충돌이 생기면 직접 질문하지 말고 `CODE_REVIEW-local-G08.md`의 `사용자 리뷰 요청` 섹션을 증거와 함께 채운 뒤 멈춘다. `request_user_input`, 채팅 선택지 제시, `USER_REVIEW.md` 생성, `complete.log` 작성, active 파일 아카이브는 구현 에이전트가 하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청 사유가 아니다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `client-state-split`: Flutter client의 bootstrap, status state, DTO, repository, panel widget 경계를 분리하고 Firebase/Mattermost 같은 외부 통합은 선택적으로 초기화되게 한다. +- Completion mode: check-on-pass + +## 리뷰 결과 요약 + +첫 리뷰는 `FAIL`이다. `ControlPlaneStatusController`가 `ClientHomePage.dispose()` 이후에도 진행 중이던 repository Future 완료 결과를 state에 반영하고 `notifyListeners()`를 호출할 수 있다. 이전 widget state 구현은 await 이후 `mounted` guard를 두었으므로, controller 분리 후에도 동일한 lifecycle 안전성이 필요하다. + +관련 로그: + +- `agent-task/m-architecture-refactor-foundation/08_client_state_split/plan_local_G08_0.log` +- `agent-task/m-architecture-refactor-foundation/08_client_state_split/code_review_cloud_G08_0.log` + +## 범위 결정 근거 + +- 수정 범위는 `apps/client/lib/control_plane_status_controller.dart`와 `apps/client/test/control_plane_status_controller_test.dart`를 우선한다. +- 필요하면 test fixture를 위해 `apps/client/test/widget_test.dart`의 fake repository만 최소 조정한다. +- bootstrap, DTO/repository barrel, panel widget 파일 구조, Control Plane Go 코드는 이 follow-up 범위가 아니다. +- review 중 이미 `apps/client/lib/widgets/execution_logs_panel.dart`, `apps/client/lib/widgets/nodes_panel.dart`의 analyzer-only `unnecessary_const` nit는 정리되어 있다. 이 변경은 유지하되 별도 기능 범위로 확대하지 않는다. + +## 빌드 등급 + +- build lane: `local-G08` +- review lane: `local-G08` +- 이유: Required 이슈가 explicit, deterministic, unit-testable lifecycle bug이므로 넓은 cloud context 없이도 검증 가능하다. 단, client 테스트 규칙상 최종 검증은 원격 runner 또는 동기화된 원격 임시 checkout에서 수행한다. + +## 구현 체크리스트 + +- [ ] `ControlPlaneStatusController`가 dispose 이후 진행 중 fetch 결과를 state에 반영하거나 `notifyListeners()`를 호출하지 않도록 lifecycle guard를 추가한다. +- [ ] `fetchEdges`, `fetchEdgeStatus`, `fetchEdgeEvents`, `fetchEdgeDetails`, `selectEdge`의 success/error path가 disposed state를 안전하게 무시하도록 정리한다. +- [ ] controller unit test에 dispose 중 in-flight future 완료 회귀 테스트를 추가한다. +- [ ] 변경분을 원격 runner 또는 동기화된 원격 임시 checkout에 반영한 뒤 `cd apps/client && flutter test`, `cd apps/client && flutter analyze --no-fatal-infos`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [REVIEW_REFACTOR-1] Dispose-safe status controller lifecycle + +### 문제 + +`ControlPlaneStatusController`는 `ChangeNotifier`이지만 dispose 상태를 추적하지 않는다. `fetchEdges`, `fetchEdgeStatus`, `fetchEdgeEvents`는 await 이후 곧바로 내부 state를 바꾸고 `notifyListeners()`를 호출한다. + +Before: + +```dart +final status = await statusRepository.fetchEdgeStatus(edgeId); +_edgeStatuses[edgeId] = status; +_loadingStatuses[edgeId] = false; +notifyListeners(); +``` + +`ClientHomePage.dispose()`는 controller를 dispose하므로 화면이 사라진 뒤 Future가 완료되면 disposed notifier가 알림을 보내 debug/test assert 또는 lifecycle 오류를 만들 수 있다. + +### 해결 방법 + +- controller에 private disposed flag를 둔다. +- `dispose()`를 override해 flag를 세운 뒤 `super.dispose()`를 호출한다. +- public mutation entrypoint와 await 이후 continuation에서 disposed 상태면 state mutation과 notify를 중단한다. +- `notifyListeners()` 호출은 helper로 감싸 disposed 상태에서 호출되지 않게 한다. +- dispose 이후 완료된 Future는 성공/실패 모두 state에 반영하지 않는다. + +After 방향: + +```dart +if (_disposed) return; +final status = await statusRepository.fetchEdgeStatus(edgeId); +if (_disposed) return; +_edgeStatuses[edgeId] = status; +_loadingStatuses[edgeId] = false; +_notifyIfActive(); +``` + +### 수정 파일 및 체크리스트 + +- [ ] `apps/client/lib/control_plane_status_controller.dart`: disposed guard, safe notify helper, await continuation guard 추가. +- [ ] `apps/client/test/control_plane_status_controller_test.dart`: in-flight fetch가 dispose 후 완료되어도 throw하지 않고 state를 갱신하지 않는 테스트 추가. +- [ ] 필요 시 `apps/client/test/widget_test.dart`: fake repository에 controllable completer fixture를 최소 추가. + +### 테스트 작성 + +- test name 후보: `ignores in-flight fetch completions after dispose` +- fixture: `Completer`, `Completer`, `Completer>`를 사용하는 fake repository. +- assertion: + - `controller.fetchEdges()` 또는 `controller.fetchEdgeDetails('edge-a')`를 시작한다. + - controller를 dispose한다. + - completer를 complete한다. + - Future가 `completes`이고 disposed `ChangeNotifier` assert가 발생하지 않는다. + - dispose 이후 완료 결과가 `edges`, `edgeStatuses`, `edgeEvents`, loading/error maps에 새로 반영되지 않는다. + +### 검증 + +원격 runner 또는 동기화된 원격 임시 checkout 기준: + +```bash +cd apps/client && flutter test +cd apps/client && flutter analyze --no-fatal-infos +``` + +검증 출력은 `CODE_REVIEW-local-G08.md`에 원문으로 기록한다. diff --git a/agent-task/m-architecture-refactor-foundation/08_client_state_split/CODE_REVIEW-cloud-G08.md b/agent-task/m-architecture-refactor-foundation/08_client_state_split/CODE_REVIEW-cloud-G08.md deleted file mode 100644 index ce1eb53..0000000 --- a/agent-task/m-architecture-refactor-foundation/08_client_state_split/CODE_REVIEW-cloud-G08.md +++ /dev/null @@ -1,170 +0,0 @@ - - -# Code Review Reference - REFACTOR - -> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** -> The task is NOT complete until every implementation-owned section below is filled in. -> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. -> Fill implementation-owned sections, then stop with active files in place and report ready for review. -> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. -> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. -> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. -> Follow the ownership table at the bottom of this file for which sections you own. - -## 개요 - -date=2026-06-06 -task=m-architecture-refactor-foundation/08_client_state_split, plan=0, tag=REFACTOR - -## Roadmap Targets - -- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` -- Task ids: - - `client-state-split`: Flutter client의 bootstrap, status state, DTO, repository, panel widget 경계를 분리하고 Firebase/Mattermost 같은 외부 통합은 선택적으로 초기화되게 한다. -- Completion mode: check-on-pass - -## 이 파일을 읽는 리뷰 에이전트에게 - -> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. - -각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. - -1. 판정을 append한다. -2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-local-G08.md` → `plan_local_G08_M.log`로 아카이브한다. -3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/08_client_state_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. -4. PASS이고 task group이 `m-architecture-refactor-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. -5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. - ---- - -## 구현 항목별 완료 여부 - -| 항목 | 완료 여부 | -|------|---------| -| [REFACTOR-1] Optional Bootstrap 경계 | [ ] | -| [REFACTOR-2] Status State Controller 분리 | [ ] | -| [REFACTOR-3] DTO와 Repository 파일 분리 | [ ] | -| [REFACTOR-4] Home Shell과 Panel Widget 파일 경계 | [ ] | - -## 구현 체크리스트 - -- [ ] bootstrap을 옵션화해 Firebase/Mattermost 초기화를 기본 on, test/embedded 경로에서는 off로 선택할 수 있게 한다. -- [ ] fleet/status/events selected Edge state를 controller/repository state 경계로 분리하고 stale cache cleanup을 unit test로 검증한다. -- [ ] DTO와 HTTP repository를 별도 파일로 나누고 기존 public import 호환을 유지한다. -- [ ] `ClientHomePage` shell composition과 panel widgets 파일 경계를 나누되 기존 visible behavior를 유지한다. -- [ ] 변경분을 원격 runner에 동기화한 뒤 `cd apps/client && flutter test`, `cd apps/client && flutter analyze --no-fatal-infos`를 실행한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. - -## 코드리뷰 전용 체크리스트 - -> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. -> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. - -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_local_G08_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/08_client_state_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/08_client_state_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. -- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. -- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. -- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. - -## 계획 대비 변경 사항 - -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ - -## 주요 설계 결정 - -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - -## 사용자 리뷰 요청 - -_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ - -- 상태: 없음 -- 사유 유형: 없음 -- 결정 필요: 없음 -- 차단 근거: 없음 -- 실행한 검증/명령: 없음 -- 자동 후속 불가 이유: 없음 -- 재개 조건: 없음 - -## 리뷰어를 위한 체크포인트 - -- production bootstrap 기본값이 Firebase/Mattermost를 계속 초기화하고, test/embedded path에서 외부 init을 끌 수 있는지 확인한다. -- status controller가 selected Edge disappearance와 stale status/events/loading/error cache cleanup을 widget lifecycle 밖에서 검증하는지 확인한다. -- `control_plane_status_client.dart` compatibility export가 기존 imports를 깨지 않는지 확인한다. -- panel 파일 분리 후 UI text, navigation, Runtime command gating, mobile layout tests가 유지되는지 확인한다. -- 원격 runner 검증이 local 변경분 동기화 이후 실행된 증거인지 확인한다. - -## 검증 결과 - -_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ - -필수 규칙: -- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. -- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. -- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. -- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. -- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. - -### REFACTOR-1 중간 검증 -```bash -$ cd apps/client && flutter test -(output) -$ cd apps/client && flutter analyze --no-fatal-infos -(output) -``` - -### REFACTOR-2 중간 검증 -```bash -$ cd apps/client && flutter test -(output) -$ cd apps/client && flutter analyze --no-fatal-infos -(output) -``` - -### REFACTOR-3 중간 검증 -```bash -$ cd apps/client && flutter test -(output) -$ cd apps/client && flutter analyze --no-fatal-infos -(output) -``` - -### REFACTOR-4 중간 검증 -```bash -$ cd apps/client && flutter test -(output) -$ cd apps/client && flutter analyze --no-fatal-infos -(output) -``` - -### 최종 검증 -```bash -$ cd apps/client && flutter test -(output) -$ cd apps/client && flutter analyze --no-fatal-infos -(output) -``` - ---- - -## Ownership - -| Section | Owner | Note | -|---------|-------|------| -| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | -| Roadmap Targets | Fixed at stub creation | Implementing agent must not modify | -| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only | -| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only | -| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | -| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders | -| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless blocked by user-only decision | -| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus | -| 검증 결과 | Implementing agent | Paste actual stdout/stderr | diff --git a/apps/client/lib/client_bootstrap.dart b/apps/client/lib/client_bootstrap.dart new file mode 100644 index 0000000..40c503b --- /dev/null +++ b/apps/client/lib/client_bootstrap.dart @@ -0,0 +1,46 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; +import 'main.dart'; +import 'src/integrations/mattermost/mattermost_push_host_integration.dart'; +import 'src/integrations/mattermost/mattermost_push_plugin_client.dart'; + +class IopClientBootstrapOptions { + final bool initializeFirebase; + final bool initializeMattermost; + final MattermostPushHostIntegration? mattermostHost; + + const IopClientBootstrapOptions({ + this.initializeFirebase = true, + this.initializeMattermost = true, + this.mattermostHost, + }); +} + +final MattermostPushHostIntegration defaultMattermostHost = MattermostPushHostIntegration( + pushClient: MattermostPushPluginClient(), +); + +Future bootstrapIopClient({ + IopClientBootstrapOptions options = const IopClientBootstrapOptions(), +}) async { + applyFullscreenMode(); + if (options.initializeFirebase) { + await Firebase.initializeApp(); + } + if (options.initializeMattermost) { + final host = options.mattermostHost ?? defaultMattermostHost; + await host.initialize(); + } +} + +Future runIopClient({ + IopClientBootstrapOptions options = const IopClientBootstrapOptions(), +}) async { + WidgetsFlutterBinding.ensureInitialized(); + await bootstrapIopClient(options: options); + runApp(IopClientApp( + mattermostHost: options.initializeMattermost + ? (options.mattermostHost ?? defaultMattermostHost) + : null, + )); +} diff --git a/apps/client/lib/client_home_page.dart b/apps/client/lib/client_home_page.dart new file mode 100644 index 0000000..b507856 --- /dev/null +++ b/apps/client/lib/client_home_page.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:iop_console/iop_console.dart'; + +import 'client_config.dart'; +import 'control_plane_status_client.dart'; +import 'control_plane_status_controller.dart'; +import 'control_plane_status_widgets.dart'; +import 'iop_wire/client_wire_client.dart'; +import 'main.dart'; +import 'src/integrations/mattermost/mattermost_push_host_integration.dart'; + +class ClientHomePage extends StatefulWidget { + final ClientWireClient? testClient; + final MattermostPushHostIntegration? mattermostHost; + final ControlPlaneStatusRepository? statusRepository; + + const ClientHomePage({ + super.key, + this.testClient, + this.mattermostHost, + this.statusRepository, + }); + + @override + State createState() => _ClientHomePageState(); +} + +class _ClientHomePageState extends State { + String _wireStatus = 'Disconnected'; + ClientWireClient? _client; + StreamSubscription? _notificationSubscription; + + late final ControlPlaneStatusController _controller; + + @override + void initState() { + super.initState(); + final statusRepo = widget.statusRepository ?? + HttpControlPlaneStatusRepository( + baseUrl: ClientConfig.controlPlaneHttpUrl, + ); + _controller = ControlPlaneStatusController(statusRepository: statusRepo); + + // Auto-connect on start + WidgetsBinding.instance.addPostFrameCallback((_) { + applyFullscreenMode(); + _connectAndFetch(); + }); + + final host = widget.mattermostHost; + if (host != null) { + _notificationSubscription = host.onNotification.listen( + _showMattermostNotification, + ); + } + } + + @override + void dispose() { + _notificationSubscription?.cancel(); + _client?.close(); + _controller.dispose(); + super.dispose(); + } + + void _showMattermostNotification(Map data) { + if (data['type'] != 'message' || !mounted) return; + + final message = data['message'] as String? ?? ''; + final channel = data['channel_name'] as String? ?? ''; + final sender = data['sender_name'] as String? ?? ''; + final content = sender.isNotEmpty + ? '[$channel] $sender: $message' + : message; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(content, maxLines: 2, overflow: TextOverflow.ellipsis), + duration: const Duration(seconds: 4), + behavior: SnackBarBehavior.floating, + ), + ); + } + + Future _connectAndFetch() async { + await Future.wait([ + _connectWire(), + _controller.fetchEdges(), + ]); + } + + Future _connectWire() async { + if (!mounted) return; + setState(() { + _wireStatus = 'Connecting'; + }); + + try { + ClientWireClient client; + if (widget.testClient != null) { + client = widget.testClient!; + } else { + client = await ClientWireClient.connectToUrl( + ClientConfig.controlPlaneWireUrl, + ); + } + _client = client; + + // Hello handshake + final response = await client.hello( + clientId: 'client-ui', + clientVersion: '1.0.0', + ); + + if (!mounted) return; + setState(() { + _wireStatus = response.ready ? 'Connected' : 'Error'; + }); + + client.addDisconnectListener((_) { + if (!mounted) return; + setState(() { + _wireStatus = 'Disconnected'; + _client = null; + }); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _wireStatus = 'Error'; + _client = null; + }); + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final config = IopConsoleConfig( + controlPlaneHttpUrl: ClientConfig.controlPlaneHttpUrl, + controlPlaneWireUrl: ClientConfig.controlPlaneWireUrl, + ); + + final currentEdgeId = _controller.selectedEdgeId; + + return IopConsoleShell( + config: config, + capabilities: iopDefaultCapabilityPack, + overview: IopConsoleOverview( + config: config, + statusText: _wireStatus, + onRefresh: _connectAndFetch, + ), + edges: EdgesPanel( + edges: _controller.edges, + isLoading: _controller.loadingEdges, + error: _controller.edgesError, + selectedEdgeId: currentEdgeId, + onSelectEdge: _controller.selectEdge, + onRefresh: _controller.fetchEdges, + ), + nodes: NodesPanel( + edges: _controller.edges, + selectedEdgeId: currentEdgeId, + selectedEdgeStatus: currentEdgeId != null ? _controller.edgeStatuses[currentEdgeId] : null, + isLoading: currentEdgeId != null ? (_controller.loadingStatuses[currentEdgeId] ?? false) : false, + error: currentEdgeId != null ? _controller.statusErrors[currentEdgeId] : null, + onSelectEdge: _controller.selectEdge, + onRefresh: () { + if (currentEdgeId != null) _controller.fetchEdgeStatus(currentEdgeId); + }, + ), + runtime: RuntimePanel( + edges: _controller.edges, + selectedEdgeId: currentEdgeId, + onSelectEdge: _controller.selectEdge, + statusRepository: _controller.statusRepository, + ), + executionLogs: ExecutionLogsPanel( + edges: _controller.edges, + selectedEdgeId: currentEdgeId, + events: currentEdgeId != null ? (_controller.edgeEvents[currentEdgeId] ?? []) : [], + isLoading: currentEdgeId != null ? (_controller.loadingEvents[currentEdgeId] ?? false) : false, + error: currentEdgeId != null ? _controller.eventErrors[currentEdgeId] : null, + onSelectEdge: _controller.selectEdge, + onRefresh: () { + if (currentEdgeId != null) _controller.fetchEdgeEvents(currentEdgeId); + }, + ), + agent: const IopAgentPanel(capabilities: iopDefaultCapabilityPack), + ); + }, + ); + } +} diff --git a/apps/client/lib/control_plane_status_client.dart b/apps/client/lib/control_plane_status_client.dart index b26cead..3aec544 100644 --- a/apps/client/lib/control_plane_status_client.dart +++ b/apps/client/lib/control_plane_status_client.dart @@ -1,487 +1,2 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; - -class EdgeRegistryView { - final String edgeId; - final String edgeName; - final String version; - final List capabilities; - final String protocol; - final bool connected; - final DateTime lastSeen; - - EdgeRegistryView({ - required this.edgeId, - required this.edgeName, - required this.version, - required this.capabilities, - required this.protocol, - required this.connected, - required this.lastSeen, - }); - - factory EdgeRegistryView.fromJson(Map json) { - return EdgeRegistryView( - edgeId: json['edge_id'] as String? ?? '', - edgeName: json['edge_name'] as String? ?? '', - version: json['version'] as String? ?? '', - capabilities: List.from(json['capabilities'] ?? []), - protocol: json['protocol'] as String? ?? '', - connected: json['connected'] as bool? ?? false, - lastSeen: json['last_seen'] != null - ? DateTime.parse(json['last_seen'] as String) - : DateTime.now(), - ); - } -} - -class AdapterSummaryView { - final String type; - final bool enabled; - - AdapterSummaryView({required this.type, required this.enabled}); - - factory AdapterSummaryView.fromJson(Map json) { - return AdapterSummaryView( - type: json['type'] as String? ?? '', - enabled: json['enabled'] as bool? ?? false, - ); - } -} - -class NodeConfigSummaryView { - final List adapters; - final int concurrency; - - NodeConfigSummaryView({required this.adapters, required this.concurrency}); - - factory NodeConfigSummaryView.fromJson(Map json) { - return NodeConfigSummaryView( - adapters: (json['adapters'] as List? ?? []) - .map((e) => AdapterSummaryView.fromJson(e as Map)) - .toList(), - concurrency: json['concurrency'] as int? ?? 0, - ); - } -} - -class EdgeNodeSnapshotView { - final String nodeId; - final String alias; - final String label; - final bool connected; - final NodeConfigSummaryView? config; - - EdgeNodeSnapshotView({ - required this.nodeId, - required this.alias, - required this.label, - required this.connected, - this.config, - }); - - factory EdgeNodeSnapshotView.fromJson(Map json) { - return EdgeNodeSnapshotView( - nodeId: json['node_id'] as String? ?? '', - alias: json['alias'] as String? ?? '', - label: json['label'] as String? ?? '', - connected: json['connected'] as bool? ?? false, - config: json['config'] != null - ? NodeConfigSummaryView.fromJson(json['config'] as Map) - : null, - ); - } -} - -class EdgeCapabilitySummaryView { - final String kind; - final bool available; - final String status; - final String summary; - - EdgeCapabilitySummaryView({ - required this.kind, - required this.available, - required this.status, - required this.summary, - }); - - factory EdgeCapabilitySummaryView.fromJson(Map json) { - return EdgeCapabilitySummaryView( - kind: json['kind'] as String? ?? '', - available: json['available'] as bool? ?? false, - status: json['status'] as String? ?? '', - summary: json['summary'] as String? ?? '', - ); - } -} - -class EdgeDomainAgentSummaryView { - final String agentKind; - final bool available; - final String lifecycleState; - final String activeCommandId; - final String summary; - - EdgeDomainAgentSummaryView({ - required this.agentKind, - required this.available, - required this.lifecycleState, - required this.activeCommandId, - required this.summary, - }); - - factory EdgeDomainAgentSummaryView.fromJson(Map json) { - return EdgeDomainAgentSummaryView( - agentKind: json['agent_kind'] as String? ?? '', - available: json['available'] as bool? ?? false, - lifecycleState: json['lifecycle_state'] as String? ?? '', - activeCommandId: json['active_command_id'] as String? ?? '', - summary: json['summary'] as String? ?? '', - ); - } -} - -class EdgeStatusResponseView { - final String requestId; - final String edgeId; - final String edgeName; - final int observedTimeUnixNano; - final List nodes; - final List capabilities; - final List domainAgents; - final Map metadata; - final String error; - - EdgeStatusResponseView({ - required this.requestId, - required this.edgeId, - required this.edgeName, - required this.observedTimeUnixNano, - required this.nodes, - required this.capabilities, - required this.domainAgents, - required this.metadata, - required this.error, - }); - - factory EdgeStatusResponseView.fromJson(Map json) { - return EdgeStatusResponseView( - requestId: json['request_id'] as String? ?? '', - edgeId: json['edge_id'] as String? ?? '', - edgeName: json['edge_name'] as String? ?? '', - observedTimeUnixNano: json['observed_time_unix_nano'] as int? ?? 0, - nodes: (json['nodes'] as List? ?? []) - .map((e) => EdgeNodeSnapshotView.fromJson(e as Map)) - .toList(), - capabilities: (json['capabilities'] as List? ?? []) - .map((e) => EdgeCapabilitySummaryView.fromJson(e as Map)) - .toList(), - domainAgents: (json['domain_agents'] as List? ?? []) - .map((e) => EdgeDomainAgentSummaryView.fromJson(e as Map)) - .toList(), - metadata: Map.from(json['metadata'] ?? {}), - error: json['error'] as String? ?? '', - ); - } -} - -class EdgeNodeEventView { - final String edgeId; - final String eventId; - final String type; - final String source; - final String nodeId; - final String alias; - final String reason; - final DateTime timestamp; - final DateTime receivedAt; - final Map metadata; - - EdgeNodeEventView({ - required this.edgeId, - required this.eventId, - required this.type, - required this.source, - required this.nodeId, - required this.alias, - required this.reason, - required this.timestamp, - required this.receivedAt, - required this.metadata, - }); - - factory EdgeNodeEventView.fromJson(Map json) { - return EdgeNodeEventView( - edgeId: json['edge_id'] as String? ?? '', - eventId: json['event_id'] as String? ?? '', - type: json['type'] as String? ?? '', - source: json['source'] as String? ?? '', - nodeId: json['node_id'] as String? ?? '', - alias: json['alias'] as String? ?? '', - reason: json['reason'] as String? ?? '', - timestamp: json['timestamp'] != null - ? DateTime.parse(json['timestamp'] as String) - : DateTime.now(), - receivedAt: json['received_at'] != null - ? DateTime.parse(json['received_at'] as String) - : DateTime.now(), - metadata: Map.from(json['metadata'] ?? {}), - ); - } -} - -class FleetEdgeView { - final String edgeId; - final String edgeName; - final String version; - final String protocol; - final bool connected; - final String health; - final DateTime lastSeen; - final int nodeCount; - final List capabilities; - final List domainAgents; - final String error; - - FleetEdgeView({ - required this.edgeId, - required this.edgeName, - required this.version, - required this.protocol, - required this.connected, - required this.health, - required this.lastSeen, - required this.nodeCount, - required this.capabilities, - required this.domainAgents, - required this.error, - }); - - factory FleetEdgeView.fromJson(Map json) { - return FleetEdgeView( - edgeId: json['edge_id'] as String? ?? '', - edgeName: json['edge_name'] as String? ?? '', - version: json['version'] as String? ?? '', - protocol: json['protocol'] as String? ?? '', - connected: json['connected'] as bool? ?? false, - health: json['health'] as String? ?? '', - lastSeen: json['last_seen'] != null - ? DateTime.parse(json['last_seen'] as String) - : DateTime.now(), - nodeCount: json['node_count'] as int? ?? 0, - capabilities: (json['capabilities'] as List? ?? []) - .map((e) => EdgeCapabilitySummaryView.fromJson(e as Map)) - .toList(), - domainAgents: (json['domain_agents'] as List? ?? []) - .map((e) => EdgeDomainAgentSummaryView.fromJson(e as Map)) - .toList(), - error: json['error'] as String? ?? '', - ); - } -} - -class FleetStatusResponseView { - final DateTime generatedAt; - final List edges; - - FleetStatusResponseView({ - required this.generatedAt, - required this.edges, - }); - - factory FleetStatusResponseView.fromJson(Map json) { - return FleetStatusResponseView( - generatedAt: json['generated_at'] != null - ? DateTime.parse(json['generated_at'] as String) - : DateTime.now(), - edges: (json['edges'] as List? ?? []) - .map((e) => FleetEdgeView.fromJson(e as Map)) - .toList(), - ); - } -} - -class EdgeCommandRecordView { - final String edgeId; - final String commandId; - final String operation; - final String targetSelector; - final String status; - final String summary; - final String error; - - EdgeCommandRecordView({ - required this.edgeId, - required this.commandId, - required this.operation, - required this.targetSelector, - required this.status, - required this.summary, - required this.error, - }); - - factory EdgeCommandRecordView.fromJson(Map json) { - return EdgeCommandRecordView( - edgeId: json['edge_id'] as String? ?? '', - commandId: json['command_id'] as String? ?? '', - operation: json['operation'] as String? ?? '', - targetSelector: json['target_selector'] as String? ?? '', - status: json['status'] as String? ?? '', - summary: json['summary'] as String? ?? '', - error: json['error'] as String? ?? '', - ); - } -} - -class EdgeOperationsResponseView { - final String edgeId; - final List operations; - - EdgeOperationsResponseView({ - required this.edgeId, - required this.operations, - }); - - factory EdgeOperationsResponseView.fromJson(Map json) { - return EdgeOperationsResponseView( - edgeId: json['edge_id'] as String? ?? '', - operations: (json['operations'] as List? ?? []) - .map((e) => EdgeCommandRecordView.fromJson(e as Map)) - .toList(), - ); - } -} - -class EdgeCommandResponseView { - final String requestId; - final String commandId; - final String edgeId; - final String status; - final String summary; - final String error; - - EdgeCommandResponseView({ - required this.requestId, - required this.commandId, - required this.edgeId, - required this.status, - required this.summary, - required this.error, - }); - - factory EdgeCommandResponseView.fromJson(Map json) { - return EdgeCommandResponseView( - requestId: json['request_id'] as String? ?? '', - commandId: json['command_id'] as String? ?? '', - edgeId: json['edge_id'] as String? ?? '', - status: json['status'] as String? ?? '', - summary: json['summary'] as String? ?? '', - error: json['error'] as String? ?? '', - ); - } -} - -abstract class ControlPlaneStatusRepository { - Future> fetchEdges(); - Future fetchEdgeStatus(String edgeId); - Future> fetchEdgeEvents(String edgeId); - Future fetchFleetStatus(); - Future fetchEdgeOperations(String edgeId); - Future sendEdgeCommand( - String edgeId, - String operation, { - String? targetSelector, - Map? parameters, - }); -} - -class HttpControlPlaneStatusRepository implements ControlPlaneStatusRepository { - final String baseUrl; - final http.Client? client; - - HttpControlPlaneStatusRepository({required this.baseUrl, this.client}); - - http.Client get _httpClient => client ?? http.Client(); - - @override - Future> fetchEdges() async { - final response = await _httpClient.get(Uri.parse('$baseUrl/edges')); - if (response.statusCode != 200) { - throw Exception('Failed to fetch edges: ${response.statusCode}'); - } - final body = json.decode(response.body) as Map; - final list = body['edges'] as List? ?? []; - return list.map((e) => EdgeRegistryView.fromJson(e as Map)).toList(); - } - - @override - Future fetchEdgeStatus(String edgeId) async { - final response = await _httpClient.get(Uri.parse('$baseUrl/edges/$edgeId/status')); - if (response.statusCode != 200) { - throw Exception('Failed to fetch status for $edgeId: ${response.statusCode}'); - } - final body = json.decode(response.body) as Map; - return EdgeStatusResponseView.fromJson(body); - } - - @override - Future> fetchEdgeEvents(String edgeId) async { - final response = await _httpClient.get(Uri.parse('$baseUrl/edges/$edgeId/events')); - if (response.statusCode != 200) { - throw Exception('Failed to fetch events for $edgeId: ${response.statusCode}'); - } - final body = json.decode(response.body) as Map; - final list = body['events'] as List? ?? []; - return list.map((e) => EdgeNodeEventView.fromJson(e as Map)).toList(); - } - - @override - Future fetchFleetStatus() async { - final response = await _httpClient.get(Uri.parse('$baseUrl/fleet/status')); - if (response.statusCode != 200) { - throw Exception('Failed to fetch fleet status: ${response.statusCode}'); - } - final body = json.decode(response.body) as Map; - return FleetStatusResponseView.fromJson(body); - } - - @override - Future fetchEdgeOperations(String edgeId) async { - final response = await _httpClient.get(Uri.parse('$baseUrl/edges/$edgeId/operations')); - if (response.statusCode != 200) { - throw Exception('Failed to fetch operations for $edgeId: ${response.statusCode}'); - } - final body = json.decode(response.body) as Map; - return EdgeOperationsResponseView.fromJson(body); - } - - @override - Future sendEdgeCommand( - String edgeId, - String operation, { - String? targetSelector, - Map? parameters, - }) async { - final Map requestBody = { - 'operation': operation, - }; - if (targetSelector != null) { - requestBody['target_selector'] = targetSelector; - } - if (parameters != null) { - requestBody['parameters'] = parameters; - } - - final response = await _httpClient.post( - Uri.parse('$baseUrl/edges/$edgeId/commands'), - headers: {'Content-Type': 'application/json'}, - body: json.encode(requestBody), - ); - if (response.statusCode != 200 && response.statusCode != 202) { - throw Exception('Failed to send command to $edgeId: ${response.statusCode}'); - } - final body = json.decode(response.body) as Map; - return EdgeCommandResponseView.fromJson(body); - } -} +export 'control_plane_status_dto.dart'; +export 'control_plane_status_repository.dart'; diff --git a/apps/client/lib/control_plane_status_controller.dart b/apps/client/lib/control_plane_status_controller.dart new file mode 100644 index 0000000..5c53854 --- /dev/null +++ b/apps/client/lib/control_plane_status_controller.dart @@ -0,0 +1,161 @@ +import 'package:flutter/foundation.dart'; +import 'control_plane_status_client.dart'; + +class ControlPlaneStatusController extends ChangeNotifier { + final ControlPlaneStatusRepository statusRepository; + + List _edges = []; + bool _loadingEdges = false; + String? _edgesError; + String? _selectedEdgeId; + + final Map _edgeStatuses = {}; + final Map _loadingStatuses = {}; + final Map _statusErrors = {}; + + final Map> _edgeEvents = {}; + final Map _loadingEvents = {}; + final Map _eventErrors = {}; + + bool _disposed = false; + + ControlPlaneStatusController({required this.statusRepository}); + + // Getters + List get edges => _edges; + bool get loadingEdges => _loadingEdges; + String? get edgesError => _edgesError; + String? get selectedEdgeId => _selectedEdgeId; + + Map get edgeStatuses => _edgeStatuses; + Map get loadingStatuses => _loadingStatuses; + Map get statusErrors => _statusErrors; + + Map> get edgeEvents => _edgeEvents; + Map get loadingEvents => _loadingEvents; + Map get eventErrors => _eventErrors; + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + void _notifyIfActive() { + if (!_disposed) { + notifyListeners(); + } + } + + void selectEdge(String edgeId) { + if (_disposed) return; + if (_selectedEdgeId == edgeId) return; + _selectedEdgeId = edgeId; + _notifyIfActive(); + fetchEdgeDetails(edgeId); + } + + Future fetchEdges() async { + if (_disposed) return; + _loadingEdges = true; + _edgesError = null; + _notifyIfActive(); + + try { + final fleetResponse = await statusRepository.fetchFleetStatus(); + if (_disposed) return; + _edges = fleetResponse.edges; + _loadingEdges = false; + + // Normalize selection: check if the previously selected edge is still present + final hasSelected = _edges.any((e) => e.edgeId == _selectedEdgeId); + if (!hasSelected) { + // Clear stale status/events/loading/error cache to prevent leak/inconsistency + if (_selectedEdgeId != null) { + _edgeStatuses.remove(_selectedEdgeId); + _loadingStatuses.remove(_selectedEdgeId); + _statusErrors.remove(_selectedEdgeId); + _edgeEvents.remove(_selectedEdgeId); + _loadingEvents.remove(_selectedEdgeId); + _eventErrors.remove(_selectedEdgeId); + } + + if (_edges.isNotEmpty) { + _selectedEdgeId = _edges.first.edgeId; + } else { + _selectedEdgeId = null; + } + } + _notifyIfActive(); + + if (_selectedEdgeId != null) { + await fetchEdgeDetails(_selectedEdgeId!); + } + } catch (e) { + if (_disposed) return; + _edgesError = e.toString(); + _loadingEdges = false; + _notifyIfActive(); + } + } + + Future fetchEdgeDetails(String edgeId) async { + if (_disposed) return; + await Future.wait([fetchEdgeStatus(edgeId), fetchEdgeEvents(edgeId)]); + if (_disposed) return; + } + + Future fetchEdgeStatus(String edgeId) async { + if (_disposed) return; + _loadingStatuses[edgeId] = true; + _statusErrors[edgeId] = null; + _notifyIfActive(); + + try { + final status = await statusRepository.fetchEdgeStatus(edgeId); + if (_disposed) return; + _edgeStatuses[edgeId] = status; + _loadingStatuses[edgeId] = false; + _notifyIfActive(); + } catch (e) { + if (_disposed) return; + _statusErrors[edgeId] = e.toString(); + _loadingStatuses[edgeId] = false; + _notifyIfActive(); + } + } + + Future fetchEdgeEvents(String edgeId) async { + if (_disposed) return; + _loadingEvents[edgeId] = true; + _eventErrors[edgeId] = null; + _notifyIfActive(); + + try { + final events = await statusRepository.fetchEdgeEvents(edgeId); + if (_disposed) return; + _edgeEvents[edgeId] = events; + _loadingEvents[edgeId] = false; + _notifyIfActive(); + } catch (e) { + if (_disposed) return; + _eventErrors[edgeId] = e.toString(); + _loadingEvents[edgeId] = false; + _notifyIfActive(); + } + } + + Future refreshSelectedStatus() async { + if (_disposed) return; + if (_selectedEdgeId != null) { + await fetchEdgeStatus(_selectedEdgeId!); + } + } + + Future refreshSelectedEvents() async { + if (_disposed) return; + if (_selectedEdgeId != null) { + await fetchEdgeEvents(_selectedEdgeId!); + } + } +} diff --git a/apps/client/lib/control_plane_status_dto.dart b/apps/client/lib/control_plane_status_dto.dart new file mode 100644 index 0000000..e640d95 --- /dev/null +++ b/apps/client/lib/control_plane_status_dto.dart @@ -0,0 +1,380 @@ +class EdgeRegistryView { + final String edgeId; + final String edgeName; + final String version; + final List capabilities; + final String protocol; + final bool connected; + final DateTime lastSeen; + + EdgeRegistryView({ + required this.edgeId, + required this.edgeName, + required this.version, + required this.capabilities, + required this.protocol, + required this.connected, + required this.lastSeen, + }); + + factory EdgeRegistryView.fromJson(Map json) { + return EdgeRegistryView( + edgeId: json['edge_id'] as String? ?? '', + edgeName: json['edge_name'] as String? ?? '', + version: json['version'] as String? ?? '', + capabilities: List.from(json['capabilities'] ?? []), + protocol: json['protocol'] as String? ?? '', + connected: json['connected'] as bool? ?? false, + lastSeen: json['last_seen'] != null + ? DateTime.parse(json['last_seen'] as String) + : DateTime.now(), + ); + } +} + +class AdapterSummaryView { + final String type; + final bool enabled; + + AdapterSummaryView({required this.type, required this.enabled}); + + factory AdapterSummaryView.fromJson(Map json) { + return AdapterSummaryView( + type: json['type'] as String? ?? '', + enabled: json['enabled'] as bool? ?? false, + ); + } +} + +class NodeConfigSummaryView { + final List adapters; + final int concurrency; + + NodeConfigSummaryView({required this.adapters, required this.concurrency}); + + factory NodeConfigSummaryView.fromJson(Map json) { + return NodeConfigSummaryView( + adapters: (json['adapters'] as List? ?? []) + .map((e) => AdapterSummaryView.fromJson(e as Map)) + .toList(), + concurrency: json['concurrency'] as int? ?? 0, + ); + } +} + +class EdgeNodeSnapshotView { + final String nodeId; + final String alias; + final String label; + final bool connected; + final NodeConfigSummaryView? config; + + EdgeNodeSnapshotView({ + required this.nodeId, + required this.alias, + required this.label, + required this.connected, + this.config, + }); + + factory EdgeNodeSnapshotView.fromJson(Map json) { + return EdgeNodeSnapshotView( + nodeId: json['node_id'] as String? ?? '', + alias: json['alias'] as String? ?? '', + label: json['label'] as String? ?? '', + connected: json['connected'] as bool? ?? false, + config: json['config'] != null + ? NodeConfigSummaryView.fromJson(json['config'] as Map) + : null, + ); + } +} + +class EdgeCapabilitySummaryView { + final String kind; + final bool available; + final String status; + final String summary; + + EdgeCapabilitySummaryView({ + required this.kind, + required this.available, + required this.status, + required this.summary, + }); + + factory EdgeCapabilitySummaryView.fromJson(Map json) { + return EdgeCapabilitySummaryView( + kind: json['kind'] as String? ?? '', + available: json['available'] as bool? ?? false, + status: json['status'] as String? ?? '', + summary: json['summary'] as String? ?? '', + ); + } +} + +class EdgeDomainAgentSummaryView { + final String agentKind; + final bool available; + final String lifecycleState; + final String activeCommandId; + final String summary; + + EdgeDomainAgentSummaryView({ + required this.agentKind, + required this.available, + required this.lifecycleState, + required this.activeCommandId, + required this.summary, + }); + + factory EdgeDomainAgentSummaryView.fromJson(Map json) { + return EdgeDomainAgentSummaryView( + agentKind: json['agent_kind'] as String? ?? '', + available: json['available'] as bool? ?? false, + lifecycleState: json['lifecycle_state'] as String? ?? '', + activeCommandId: json['active_command_id'] as String? ?? '', + summary: json['summary'] as String? ?? '', + ); + } +} + +class EdgeStatusResponseView { + final String requestId; + final String edgeId; + final String edgeName; + final int observedTimeUnixNano; + final List nodes; + final List capabilities; + final List domainAgents; + final Map metadata; + final String error; + + EdgeStatusResponseView({ + required this.requestId, + required this.edgeId, + required this.edgeName, + required this.observedTimeUnixNano, + required this.nodes, + required this.capabilities, + required this.domainAgents, + required this.metadata, + required this.error, + }); + + factory EdgeStatusResponseView.fromJson(Map json) { + return EdgeStatusResponseView( + requestId: json['request_id'] as String? ?? '', + edgeId: json['edge_id'] as String? ?? '', + edgeName: json['edge_name'] as String? ?? '', + observedTimeUnixNano: json['observed_time_unix_nano'] as int? ?? 0, + nodes: (json['nodes'] as List? ?? []) + .map((e) => EdgeNodeSnapshotView.fromJson(e as Map)) + .toList(), + capabilities: (json['capabilities'] as List? ?? []) + .map((e) => EdgeCapabilitySummaryView.fromJson(e as Map)) + .toList(), + domainAgents: (json['domain_agents'] as List? ?? []) + .map((e) => EdgeDomainAgentSummaryView.fromJson(e as Map)) + .toList(), + metadata: Map.from(json['metadata'] ?? {}), + error: json['error'] as String? ?? '', + ); + } +} + +class EdgeNodeEventView { + final String edgeId; + final String eventId; + final String type; + final String source; + final String nodeId; + final String alias; + final String reason; + final DateTime timestamp; + final DateTime receivedAt; + final Map metadata; + + EdgeNodeEventView({ + required this.edgeId, + required this.eventId, + required this.type, + required this.source, + required this.nodeId, + required this.alias, + required this.reason, + required this.timestamp, + required this.receivedAt, + required this.metadata, + }); + + factory EdgeNodeEventView.fromJson(Map json) { + return EdgeNodeEventView( + edgeId: json['edge_id'] as String? ?? '', + eventId: json['event_id'] as String? ?? '', + type: json['type'] as String? ?? '', + source: json['source'] as String? ?? '', + nodeId: json['node_id'] as String? ?? '', + alias: json['alias'] as String? ?? '', + reason: json['reason'] as String? ?? '', + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp'] as String) + : DateTime.now(), + receivedAt: json['received_at'] != null + ? DateTime.parse(json['received_at'] as String) + : DateTime.now(), + metadata: Map.from(json['metadata'] ?? {}), + ); + } +} + +class FleetEdgeView { + final String edgeId; + final String edgeName; + final String version; + final String protocol; + final bool connected; + final String health; + final DateTime lastSeen; + final int nodeCount; + final List capabilities; + final List domainAgents; + final String error; + + FleetEdgeView({ + required this.edgeId, + required this.edgeName, + required this.version, + required this.protocol, + required this.connected, + required this.health, + required this.lastSeen, + required this.nodeCount, + required this.capabilities, + required this.domainAgents, + required this.error, + }); + + factory FleetEdgeView.fromJson(Map json) { + return FleetEdgeView( + edgeId: json['edge_id'] as String? ?? '', + edgeName: json['edge_name'] as String? ?? '', + version: json['version'] as String? ?? '', + protocol: json['protocol'] as String? ?? '', + connected: json['connected'] as bool? ?? false, + health: json['health'] as String? ?? '', + lastSeen: json['last_seen'] != null + ? DateTime.parse(json['last_seen'] as String) + : DateTime.now(), + nodeCount: json['node_count'] as int? ?? 0, + capabilities: (json['capabilities'] as List? ?? []) + .map((e) => EdgeCapabilitySummaryView.fromJson(e as Map)) + .toList(), + domainAgents: (json['domain_agents'] as List? ?? []) + .map((e) => EdgeDomainAgentSummaryView.fromJson(e as Map)) + .toList(), + error: json['error'] as String? ?? '', + ); + } +} + +class FleetStatusResponseView { + final DateTime generatedAt; + final List edges; + + FleetStatusResponseView({ + required this.generatedAt, + required this.edges, + }); + + factory FleetStatusResponseView.fromJson(Map json) { + return FleetStatusResponseView( + generatedAt: json['generated_at'] != null + ? DateTime.parse(json['generated_at'] as String) + : DateTime.now(), + edges: (json['edges'] as List? ?? []) + .map((e) => FleetEdgeView.fromJson(e as Map)) + .toList(), + ); + } +} + +class EdgeCommandRecordView { + final String edgeId; + final String commandId; + final String operation; + final String targetSelector; + final String status; + final String summary; + final String error; + + EdgeCommandRecordView({ + required this.edgeId, + required this.commandId, + required this.operation, + required this.targetSelector, + required this.status, + required this.summary, + required this.error, + }); + + factory EdgeCommandRecordView.fromJson(Map json) { + return EdgeCommandRecordView( + edgeId: json['edge_id'] as String? ?? '', + commandId: json['command_id'] as String? ?? '', + operation: json['operation'] as String? ?? '', + targetSelector: json['target_selector'] as String? ?? '', + status: json['status'] as String? ?? '', + summary: json['summary'] as String? ?? '', + error: json['error'] as String? ?? '', + ); + } +} + +class EdgeOperationsResponseView { + final String edgeId; + final List operations; + + EdgeOperationsResponseView({ + required this.edgeId, + required this.operations, + }); + + factory EdgeOperationsResponseView.fromJson(Map json) { + return EdgeOperationsResponseView( + edgeId: json['edge_id'] as String? ?? '', + operations: (json['operations'] as List? ?? []) + .map((e) => EdgeCommandRecordView.fromJson(e as Map)) + .toList(), + ); + } +} + +class EdgeCommandResponseView { + final String requestId; + final String commandId; + final String edgeId; + final String status; + final String summary; + final String error; + + EdgeCommandResponseView({ + required this.requestId, + required this.commandId, + required this.edgeId, + required this.status, + required this.summary, + required this.error, + }); + + factory EdgeCommandResponseView.fromJson(Map json) { + return EdgeCommandResponseView( + requestId: json['request_id'] as String? ?? '', + commandId: json['command_id'] as String? ?? '', + edgeId: json['edge_id'] as String? ?? '', + status: json['status'] as String? ?? '', + summary: json['summary'] as String? ?? '', + error: json['error'] as String? ?? '', + ); + } +} diff --git a/apps/client/lib/control_plane_status_repository.dart b/apps/client/lib/control_plane_status_repository.dart new file mode 100644 index 0000000..f730c6a --- /dev/null +++ b/apps/client/lib/control_plane_status_repository.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'control_plane_status_dto.dart'; + +abstract class ControlPlaneStatusRepository { + Future> fetchEdges(); + Future fetchEdgeStatus(String edgeId); + Future> fetchEdgeEvents(String edgeId); + Future fetchFleetStatus(); + Future fetchEdgeOperations(String edgeId); + Future sendEdgeCommand( + String edgeId, + String operation, { + String? targetSelector, + Map? parameters, + }); +} + +class HttpControlPlaneStatusRepository implements ControlPlaneStatusRepository { + final String baseUrl; + final http.Client? client; + + HttpControlPlaneStatusRepository({required this.baseUrl, this.client}); + + http.Client get _httpClient => client ?? http.Client(); + + @override + Future> fetchEdges() async { + final response = await _httpClient.get(Uri.parse('$baseUrl/edges')); + if (response.statusCode != 200) { + throw Exception('Failed to fetch edges: ${response.statusCode}'); + } + final body = json.decode(response.body) as Map; + final list = body['edges'] as List? ?? []; + return list.map((e) => EdgeRegistryView.fromJson(e as Map)).toList(); + } + + @override + Future fetchEdgeStatus(String edgeId) async { + final response = await _httpClient.get(Uri.parse('$baseUrl/edges/$edgeId/status')); + if (response.statusCode != 200) { + throw Exception('Failed to fetch status for $edgeId: ${response.statusCode}'); + } + final body = json.decode(response.body) as Map; + return EdgeStatusResponseView.fromJson(body); + } + + @override + Future> fetchEdgeEvents(String edgeId) async { + final response = await _httpClient.get(Uri.parse('$baseUrl/edges/$edgeId/events')); + if (response.statusCode != 200) { + throw Exception('Failed to fetch events for $edgeId: ${response.statusCode}'); + } + final body = json.decode(response.body) as Map; + final list = body['events'] as List? ?? []; + return list.map((e) => EdgeNodeEventView.fromJson(e as Map)).toList(); + } + + @override + Future fetchFleetStatus() async { + final response = await _httpClient.get(Uri.parse('$baseUrl/fleet/status')); + if (response.statusCode != 200) { + throw Exception('Failed to fetch fleet status: ${response.statusCode}'); + } + final body = json.decode(response.body) as Map; + return FleetStatusResponseView.fromJson(body); + } + + @override + Future fetchEdgeOperations(String edgeId) async { + final response = await _httpClient.get(Uri.parse('$baseUrl/edges/$edgeId/operations')); + if (response.statusCode != 200) { + throw Exception('Failed to fetch operations for $edgeId: ${response.statusCode}'); + } + final body = json.decode(response.body) as Map; + return EdgeOperationsResponseView.fromJson(body); + } + + @override + Future sendEdgeCommand( + String edgeId, + String operation, { + String? targetSelector, + Map? parameters, + }) async { + final Map requestBody = { + 'operation': operation, + }; + if (targetSelector != null) { + requestBody['target_selector'] = targetSelector; + } + if (parameters != null) { + requestBody['parameters'] = parameters; + } + + final response = await _httpClient.post( + Uri.parse('$baseUrl/edges/$edgeId/commands'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(requestBody), + ); + if (response.statusCode != 200 && response.statusCode != 202) { + throw Exception('Failed to send command to $edgeId: ${response.statusCode}'); + } + final body = json.decode(response.body) as Map; + return EdgeCommandResponseView.fromJson(body); + } +} diff --git a/apps/client/lib/control_plane_status_widgets.dart b/apps/client/lib/control_plane_status_widgets.dart index c55e83a..5771046 100644 --- a/apps/client/lib/control_plane_status_widgets.dart +++ b/apps/client/lib/control_plane_status_widgets.dart @@ -1,1506 +1,4 @@ -import 'package:flutter/material.dart'; -import 'control_plane_status_client.dart'; - -class EdgesPanel extends StatelessWidget { - final List edges; - final bool isLoading; - final String? error; - final String? selectedEdgeId; - final ValueChanged onSelectEdge; - final VoidCallback onRefresh; - - const EdgesPanel({ - super.key, - required this.edges, - required this.isLoading, - required this.error, - required this.selectedEdgeId, - required this.onSelectEdge, - required this.onRefresh, - }); - - @override - Widget build(BuildContext context) { - if (isLoading && edges.isEmpty) { - return const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), - ), - ); - } - - if (error != null && edges.isEmpty) { - return Center( - child: Container( - padding: const EdgeInsets.all(24), - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFEF4444).withValues(alpha: 0.3)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 48), - const SizedBox(height: 16), - const Text( - 'Failed to Load Edges', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 8), - Text( - error!, - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: onRefresh, - icon: const Icon(Icons.refresh), - label: const Text('Retry'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6366F1), - foregroundColor: Colors.white, - ), - ), - ], - ), - ), - ); - } - - if (edges.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.dns_outlined, color: Color(0xFF6366F1), size: 64), - const SizedBox(height: 16), - const Text( - 'No Edges Registered', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 8), - const Text( - 'Connect your edges to the Control Plane to see them here.', - style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - OutlinedButton.icon( - onPressed: onRefresh, - icon: const Icon(Icons.refresh), - label: const Text('Refresh'), - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF6366F1), - side: const BorderSide(color: Color(0xFF6366F1)), - ), - ), - ], - ), - ); - } - - final selectedEdge = edges.firstWhere( - (e) => e.edgeId == selectedEdgeId, - orElse: () => edges.first, - ); - - Widget buildEdgeList(bool isMobile) { - return Container( - decoration: BoxDecoration( - border: isMobile - ? const Border(bottom: BorderSide(color: Color(0xFF334155))) - : const Border(right: BorderSide(color: Color(0xFF334155))), - ), - child: ListView.builder( - itemCount: edges.length, - itemBuilder: (context, index) { - final edge = edges[index]; - final isSelected = edge.edgeId == selectedEdge.edgeId; - return InkWell( - onTap: () => onSelectEdge(edge.edgeId), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: isSelected ? const Color(0xFF1E293B) : Colors.transparent, - border: const Border( - bottom: BorderSide(color: Color(0xFF334155), width: 0.5), - ), - ), - child: Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: edge.connected ? const Color(0xFF10B981) : const Color(0xFF64748B), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - edge.edgeName.isNotEmpty ? edge.edgeName : edge.edgeId, - style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? const Color(0xFF6366F1) : Colors.white, - fontSize: 15, - ), - ), - const SizedBox(height: 4), - Text( - 'ID: ${edge.edgeId}', - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), - ), - ], - ), - ), - const Icon(Icons.chevron_right, color: Color(0xFF64748B), size: 16), - ], - ), - ), - ); - }, - ), - ); - } - - Widget buildEdgeDetails(bool isMobile) { - return Container( - padding: EdgeInsets.all(isMobile ? 16 : 24), - child: ListView( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - selectedEdge.edgeName.isNotEmpty ? selectedEdge.edgeName : selectedEdge.edgeId, - style: TextStyle(fontSize: isMobile ? 18 : 22, fontWeight: FontWeight.bold, color: Colors.white), - overflow: TextOverflow.ellipsis, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: selectedEdge.connected - ? const Color(0xFF10B981).withValues(alpha: 0.15) - : const Color(0xFF64748B).withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: selectedEdge.connected ? const Color(0xFF10B981) : const Color(0xFF64748B), - width: 1.5, - ), - ), - child: Text( - selectedEdge.connected ? 'CONNECTED' : 'DISCONNECTED', - style: TextStyle( - color: selectedEdge.connected ? const Color(0xFF10B981) : const Color(0xFF94A3B8), - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - const SizedBox(height: 24), - _buildDetailRow('Edge ID', selectedEdge.edgeId), - _buildDetailRow('Version', selectedEdge.version), - _buildDetailRow('Protocol', selectedEdge.protocol), - _buildDetailRow('Last Seen', selectedEdge.lastSeen.toLocal().toString()), - _buildDetailRow('Fleet Health', selectedEdge.health), - _buildDetailRow('Node Count', selectedEdge.nodeCount.toString()), - const SizedBox(height: 24), - const Text( - 'Capabilities', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 12), - if (selectedEdge.capabilities.isEmpty) - const Text('No capabilities reported', style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13)) - else - Wrap( - spacing: 8, - runSpacing: 8, - children: selectedEdge.capabilities.map((cap) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF6366F1).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFF6366F1).withValues(alpha: 0.3)), - ), - child: Text( - cap.kind.isNotEmpty ? cap.kind : cap.toString(), - style: const TextStyle(color: Color(0xFF818CF8), fontSize: 12, fontWeight: FontWeight.w500), - ), - ); - }).toList(), - ), - ], - ), - ); - } - - return LayoutBuilder( - builder: (context, constraints) { - final isMobile = constraints.maxWidth < 600; - if (isMobile) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 2, - child: buildEdgeList(true), - ), - Expanded( - flex: 3, - child: buildEdgeDetails(true), - ), - ], - ); - } else { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 2, - child: buildEdgeList(false), - ), - Expanded( - flex: 3, - child: buildEdgeDetails(false), - ), - ], - ); - } - }, - ); - } - - Widget _buildDetailRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12)), - const SizedBox(height: 4), - Text( - value.isNotEmpty ? value : '-', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 8), - const Divider(color: Color(0xFF334155), height: 1), - ], - ), - ); - } -} - -class NodesPanel extends StatelessWidget { - final List edges; - final String? selectedEdgeId; - final EdgeStatusResponseView? selectedEdgeStatus; - final bool isLoading; - final String? error; - final ValueChanged onSelectEdge; - final VoidCallback onRefresh; - - const NodesPanel({ - super.key, - required this.edges, - required this.selectedEdgeId, - required this.selectedEdgeStatus, - required this.isLoading, - required this.error, - required this.onSelectEdge, - required this.onRefresh, - }); - - @override - Widget build(BuildContext context) { - if (edges.isEmpty) { - return const Center( - child: Text( - 'Please connect and register at least one Edge first.', - style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), - ), - ); - } - - final currentEdgeId = selectedEdgeId ?? edges.first.edgeId; - final hasCurrentEdge = edges.any((e) => e.edgeId == currentEdgeId); - final dropdownValue = hasCurrentEdge ? currentEdgeId : (edges.isNotEmpty ? edges.first.edgeId : null); - - return Container( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Expanded( - child: Text( - 'Nodes View', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 16), - if (edges.isNotEmpty && dropdownValue != null) ...[ - const Text('Active Edge: ', style: TextStyle(color: Color(0xFF94A3B8))), - const SizedBox(width: 8), - DropdownButton( - value: dropdownValue, - dropdownColor: const Color(0xFF1E293B), - style: const TextStyle(color: Colors.white), - underline: const SizedBox(), - items: edges.map((e) { - return DropdownMenuItem( - value: e.edgeId, - child: Text(e.edgeName.isNotEmpty ? e.edgeName : e.edgeId), - ); - }).toList(), - onChanged: (val) { - if (val != null) onSelectEdge(val); - }, - ), - ], - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.refresh, color: Color(0xFF6366F1)), - onPressed: onRefresh, - ), - ], - ), - const SizedBox(height: 20), - Expanded(child: _buildNodeContent()), - ], - ), - ); - } - - Widget _buildNodeContent() { - if (isLoading) { - return const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), - ), - ); - } - - if (error != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 48), - const SizedBox(height: 16), - const Text( - 'Failed to Load Nodes Status', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 8), - Text(error!, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13)), - ], - ), - ); - } - - final nodes = selectedEdgeStatus?.nodes ?? []; - if (nodes.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.developer_board_off_outlined, color: Color(0xFF64748B), size: 56), - const SizedBox(height: 16), - Text( - 'No Nodes Registered', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 4), - Text( - 'No active nodes have registered on this edge.', - style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13), - ), - ], - ), - ); - } - - return ListView.builder( - itemCount: nodes.length, - itemBuilder: (context, index) { - final node = nodes[index]; - return Card( - color: const Color(0xFF1E293B), - margin: const EdgeInsets.only(bottom: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide( - color: node.connected - ? const Color(0xFF10B981).withValues(alpha: 0.3) - : const Color(0xFF334155), - ), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - node.alias.isNotEmpty ? node.alias : node.nodeId, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - 'ID: ${node.nodeId}', - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: node.connected - ? const Color(0xFF10B981).withValues(alpha: 0.1) - : const Color(0xFFEF4444).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - node.connected ? 'CONNECTED' : 'OFFLINE', - style: TextStyle( - color: node.connected ? const Color(0xFF10B981) : const Color(0xFFEF4444), - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - if (node.label.isNotEmpty) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF334155), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - 'Label: ${node.label}', - style: const TextStyle(color: Colors.white, fontSize: 12), - ), - ), - ], - if (node.config != null) ...[ - const SizedBox(height: 16), - const Divider(color: Color(0xFF334155)), - const SizedBox(height: 12), - Row( - children: [ - const Icon(Icons.bolt, size: 16, color: Color(0xFF6366F1)), - const SizedBox(width: 6), - Text( - 'Concurrency Limit: ${node.config!.concurrency}', - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13), - ), - ], - ), - const SizedBox(height: 12), - const Text( - 'Adapters', - style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - if (node.config!.adapters.isEmpty) - const Text('No active adapters configured', style: TextStyle(color: Color(0xFF94A3B8), fontSize: 12)) - else - Wrap( - spacing: 8, - runSpacing: 8, - children: node.config!.adapters.map((adapter) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: adapter.enabled - ? const Color(0xFF10B981).withValues(alpha: 0.1) - : const Color(0xFFEF4444).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: adapter.enabled ? const Color(0xFF10B981) : const Color(0xFFEF4444), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - adapter.type, - style: TextStyle( - color: adapter.enabled ? const Color(0xFF10B981) : const Color(0xFFEF4444), - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 4), - Icon( - adapter.enabled ? Icons.check : Icons.close, - size: 12, - color: adapter.enabled ? const Color(0xFF10B981) : const Color(0xFFEF4444), - ), - ], - ), - ); - }).toList(), - ), - ], - ], - ), - ), - ); - }, - ); - } -} - -class ExecutionLogsPanel extends StatelessWidget { - final List edges; - final String? selectedEdgeId; - final List events; - final bool isLoading; - final String? error; - final ValueChanged onSelectEdge; - final VoidCallback onRefresh; - - const ExecutionLogsPanel({ - super.key, - required this.edges, - required this.selectedEdgeId, - required this.events, - required this.isLoading, - required this.error, - required this.onSelectEdge, - required this.onRefresh, - }); - - @override - Widget build(BuildContext context) { - if (edges.isEmpty) { - return const Center( - child: Text( - 'Please connect and register at least one Edge first.', - style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), - ), - ); - } - - final currentEdgeId = selectedEdgeId ?? edges.first.edgeId; - final hasCurrentEdge = edges.any((e) => e.edgeId == currentEdgeId); - final dropdownValue = hasCurrentEdge ? currentEdgeId : (edges.isNotEmpty ? edges.first.edgeId : null); - - return Container( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Expanded( - child: Text( - 'Lifecycle Events & Logs', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 16), - if (edges.isNotEmpty && dropdownValue != null) ...[ - const Text('Filter Edge: ', style: TextStyle(color: Color(0xFF94A3B8))), - const SizedBox(width: 8), - DropdownButton( - value: dropdownValue, - dropdownColor: const Color(0xFF1E293B), - style: const TextStyle(color: Colors.white), - underline: const SizedBox(), - items: edges.map((e) { - return DropdownMenuItem( - value: e.edgeId, - child: Text(e.edgeName.isNotEmpty ? e.edgeName : e.edgeId), - ); - }).toList(), - onChanged: (val) { - if (val != null) onSelectEdge(val); - }, - ), - ], - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.refresh, color: Color(0xFF6366F1)), - onPressed: onRefresh, - ), - ], - ), - const SizedBox(height: 20), - Expanded(child: _buildLogsContent()), - ], - ), - ); - } - - Widget _buildLogsContent() { - if (isLoading) { - return const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), - ), - ); - } - - if (error != null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 48), - const SizedBox(height: 16), - const Text( - 'Failed to Load Events', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 8), - Text(error!, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13)), - ], - ), - ); - } - - if (events.isEmpty) { - return const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.history_toggle_off, color: Color(0xFF64748B), size: 56), - const SizedBox(height: 16), - Text( - 'No Events Captured', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 4), - Text( - 'No lifecycle events have been recorded for this edge yet.', - style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13), - ), - ], - ), - ); - } - - return ListView.builder( - itemCount: events.length, - itemBuilder: (context, index) { - final event = events[index]; - final eventColor = _getEventColor(event.type); - final eventIcon = _getEventIcon(event.type); - - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(8), - border: Border( - left: BorderSide(color: eventColor, width: 4), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(eventIcon, size: 16, color: eventColor), - const SizedBox(width: 8), - Text( - event.type.toUpperCase(), - style: TextStyle(color: eventColor, fontSize: 12, fontWeight: FontWeight.bold), - ), - const Spacer(), - Text( - event.timestamp.toLocal().toString().substring(11, 19), - style: const TextStyle(color: Color(0xFF64748B), fontSize: 12), - ), - ], - ), - const SizedBox(height: 8), - Text( - event.reason, - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 8), - Row( - children: [ - const Text('Node: ', style: TextStyle(color: Color(0xFF64748B), fontSize: 12)), - Text( - event.alias.isNotEmpty ? event.alias : (event.nodeId.isNotEmpty ? event.nodeId : 'Edge System'), - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), - ), - if (event.source.isNotEmpty) ...[ - const SizedBox(width: 12), - const Text('Source: ', style: TextStyle(color: Color(0xFF64748B), fontSize: 12)), - Text( - event.source, - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), - ), - ], - ], - ), - ], - ), - ); - }, - ); - } - - Color _getEventColor(String type) { - switch (type.toLowerCase()) { - case 'error': - case 'failed': - return const Color(0xFFEF4444); - case 'warning': - case 'warn': - return const Color(0xFFF59E0B); - case 'success': - case 'connected': - case 'online': - return const Color(0xFF10B981); - case 'info': - default: - return const Color(0xFF6366F1); - } - } - - IconData _getEventIcon(String type) { - switch (type.toLowerCase()) { - case 'error': - case 'failed': - return Icons.error_outline; - case 'warning': - case 'warn': - return Icons.warning_amber_outlined; - case 'success': - case 'connected': - case 'online': - return Icons.check_circle_outline; - case 'info': - default: - return Icons.info_outline; - } - } -} - -class RuntimePanel extends StatefulWidget { - final List edges; - final String? selectedEdgeId; - final ValueChanged onSelectEdge; - final ControlPlaneStatusRepository statusRepository; - - const RuntimePanel({ - super.key, - required this.edges, - required this.selectedEdgeId, - required this.onSelectEdge, - required this.statusRepository, - }); - - @override - State createState() => _RuntimePanelState(); -} - -class _RuntimePanelState extends State { - bool _isLoading = false; - String? _error; - EdgeOperationsResponseView? _operations; - String? _lastCommandStatus; - - @override - void initState() { - super.initState(); - _fetchOperations(); - } - - @override - void didUpdateWidget(RuntimePanel oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.selectedEdgeId != widget.selectedEdgeId) { - _fetchOperations(); - } - } - - Future _fetchOperations() async { - final edgeId = widget.selectedEdgeId; - if (edgeId == null) return; - setState(() { - _isLoading = true; - _error = null; - _operations = null; - }); - try { - final ops = await widget.statusRepository.fetchEdgeOperations(edgeId); - if (mounted && widget.selectedEdgeId == edgeId) { - setState(() { - _operations = ops; - _isLoading = false; - }); - } - } catch (e) { - if (mounted && widget.selectedEdgeId == edgeId) { - setState(() { - _error = e.toString(); - _isLoading = false; - }); - } - } - } - - Future _sendCommand(String operation) async { - final edgeId = widget.selectedEdgeId; - if (edgeId == null) return; - setState(() { - _isLoading = true; - _lastCommandStatus = null; - }); - try { - final resp = await widget.statusRepository.sendEdgeCommand(edgeId, operation); - if (mounted) { - final isError = resp.status.toLowerCase() == 'error' || - resp.status.toLowerCase() == 'failed' || - resp.status.toLowerCase() == 'unsupported' || - resp.error.isNotEmpty; - setState(() { - if (isError) { - _lastCommandStatus = 'Failed: ${resp.status} - ${resp.error.isNotEmpty ? resp.error : resp.summary}'; - } else { - _lastCommandStatus = 'Success: Command accepted (${resp.status}) - ID: ${resp.commandId}'; - } - _isLoading = false; - }); - _fetchOperations(); - } - } catch (e) { - if (mounted) { - setState(() { - _lastCommandStatus = 'Failed: ${e.toString()}'; - _isLoading = false; - }); - } - } - } - - void _showAgentCommandDialog() { - final selectorController = TextEditingController(); - final commandController = TextEditingController(); - - showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: const Color(0xFF1E293B), - title: const Text('Send Agent Command', style: TextStyle(color: Colors.white)), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: selectorController, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - labelText: 'Target Selector (e.g. node-1)', - labelStyle: TextStyle(color: Color(0xFF94A3B8)), - ), - ), - const SizedBox(height: 12), - TextField( - controller: commandController, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - labelText: 'Command Name (parameters.command)', - labelStyle: TextStyle(color: Color(0xFF94A3B8)), - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel', style: TextStyle(color: Color(0xFF94A3B8))), - ), - ElevatedButton( - onPressed: () { - final selector = selectorController.text.trim(); - final cmd = commandController.text.trim(); - if (selector.isNotEmpty && cmd.isNotEmpty) { - Navigator.pop(context); - _sendAgentCommand(selector, cmd); - } - }, - child: const Text('Send'), - ), - ], - ); - }, - ); - } - - Future _sendAgentCommand(String selector, String command) async { - final edgeId = widget.selectedEdgeId; - if (edgeId == null) return; - if (selector.isEmpty || command.isEmpty) return; - setState(() { - _isLoading = true; - _lastCommandStatus = null; - }); - try { - final resp = await widget.statusRepository.sendEdgeCommand( - edgeId, - 'agent.command', - targetSelector: selector, - parameters: {'command': command}, - ); - if (mounted) { - final isError = resp.status.toLowerCase() == 'error' || - resp.status.toLowerCase() == 'failed' || - resp.status.toLowerCase() == 'unsupported' || - resp.error.isNotEmpty; - setState(() { - if (isError) { - _lastCommandStatus = 'Failed: ${resp.status} - ${resp.error.isNotEmpty ? resp.error : resp.summary}'; - } else { - _lastCommandStatus = 'Success: Command accepted (${resp.status}) - ID: ${resp.commandId}'; - } - _isLoading = false; - }); - _fetchOperations(); - } - } catch (e) { - if (mounted) { - setState(() { - _lastCommandStatus = 'Failed: ${e.toString()}'; - _isLoading = false; - }); - } - } - } - - void _showAgentStatusDialog() { - final selectorController = TextEditingController(); - - showDialog( - context: context, - builder: (context) { - return AlertDialog( - backgroundColor: const Color(0xFF1E293B), - title: const Text('Get Agent Status', style: TextStyle(color: Colors.white)), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: selectorController, - style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( - labelText: 'Target Selector (e.g. node-1)', - labelStyle: TextStyle(color: Color(0xFF94A3B8)), - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel', style: TextStyle(color: Color(0xFF94A3B8))), - ), - ElevatedButton( - onPressed: () { - final selector = selectorController.text.trim(); - if (selector.isNotEmpty) { - Navigator.pop(context); - _sendAgentStatus(selector); - } - }, - child: const Text('Send'), - ), - ], - ); - }, - ); - } - - Future _sendAgentStatus(String selector) async { - final edgeId = widget.selectedEdgeId; - if (edgeId == null) return; - if (selector.isEmpty) return; - setState(() { - _isLoading = true; - _lastCommandStatus = null; - }); - try { - final resp = await widget.statusRepository.sendEdgeCommand( - edgeId, - 'agent.status', - targetSelector: selector, - ); - if (mounted) { - final isError = resp.status.toLowerCase() == 'error' || - resp.status.toLowerCase() == 'failed' || - resp.status.toLowerCase() == 'unsupported' || - resp.error.isNotEmpty; - setState(() { - if (isError) { - _lastCommandStatus = 'Failed: ${resp.status} - ${resp.error.isNotEmpty ? resp.error : resp.summary}'; - } else { - _lastCommandStatus = 'Success: Command accepted (${resp.status}) - ID: ${resp.commandId}'; - } - _isLoading = false; - }); - _fetchOperations(); - } - } catch (e) { - if (mounted) { - setState(() { - _lastCommandStatus = 'Failed: ${e.toString()}'; - _isLoading = false; - }); - } - } - } - - @override - Widget build(BuildContext context) { - if (widget.edges.isEmpty) { - return const Center( - child: Text( - 'Please connect and register at least one Edge first.', - style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), - ), - ); - } - - final currentEdgeId = widget.selectedEdgeId ?? widget.edges.first.edgeId; - final hasCurrentEdge = widget.edges.any((e) => e.edgeId == currentEdgeId); - final dropdownValue = hasCurrentEdge ? currentEdgeId : (widget.edges.isNotEmpty ? widget.edges.first.edgeId : null); - final selectedEdge = hasCurrentEdge ? widget.edges.firstWhere((e) => e.edgeId == currentEdgeId) : widget.edges.first; - - return Container( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LayoutBuilder( - builder: (context, headerConstraints) { - final isNarrow = headerConstraints.maxWidth < 500; - final headerTitle = const Text( - 'Operations & Domain Agents', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), - overflow: TextOverflow.ellipsis, - ); - final headerControls = Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - if (widget.edges.isNotEmpty && dropdownValue != null) ...[ - const Text('Active Edge: ', style: TextStyle(color: Color(0xFF94A3B8))), - DropdownButton( - value: dropdownValue, - dropdownColor: const Color(0xFF1E293B), - style: const TextStyle(color: Colors.white), - underline: const SizedBox(), - items: widget.edges.map((e) { - return DropdownMenuItem( - value: e.edgeId, - child: Text(e.edgeName.isNotEmpty ? e.edgeName : e.edgeId), - ); - }).toList(), - onChanged: (val) { - if (val != null) widget.onSelectEdge(val); - }, - ), - ], - IconButton( - icon: const Icon(Icons.refresh, color: Color(0xFF6366F1)), - onPressed: _fetchOperations, - ), - ], - ); - - if (isNarrow) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - headerTitle, - const SizedBox(height: 12), - headerControls, - ], - ); - } else { - return Row( - children: [ - Expanded(child: headerTitle), - const SizedBox(width: 16), - headerControls, - ], - ); - } - }, - ), - const SizedBox(height: 24), - if (_lastCommandStatus != null) ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: _lastCommandStatus!.startsWith('Success') - ? const Color(0xFF10B981).withValues(alpha: 0.1) - : const Color(0xFFEF4444).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _lastCommandStatus!.startsWith('Success') - ? const Color(0xFF10B981).withValues(alpha: 0.3) - : const Color(0xFFEF4444).withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - _lastCommandStatus!.startsWith('Success') ? Icons.check_circle_outline : Icons.error_outline, - color: _lastCommandStatus!.startsWith('Success') ? const Color(0xFF10B981) : const Color(0xFFEF4444), - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - _lastCommandStatus!, - style: TextStyle( - color: _lastCommandStatus!.startsWith('Success') ? const Color(0xFFD1FAE5) : const Color(0xFFFEE2E2), - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 16, color: Color(0xFF94A3B8)), - onPressed: () { - setState(() { - _lastCommandStatus = null; - }); - }, - ), - ], - ), - ), - const SizedBox(height: 20), - ], - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'System Gated Operations', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 12), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - ElevatedButton.icon( - onPressed: _isLoading ? null : () => _sendCommand('health.check'), - icon: const Icon(Icons.health_and_safety_outlined, size: 16), - label: const Text('Health Check'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF10B981), - foregroundColor: Colors.white, - ), - ), - ElevatedButton.icon( - onPressed: _isLoading ? null : _showAgentStatusDialog, - icon: const Icon(Icons.info_outline, size: 16), - label: const Text('Agent Status'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF3B82F6), - foregroundColor: Colors.white, - ), - ), - ElevatedButton.icon( - onPressed: _isLoading ? null : _showAgentCommandDialog, - icon: const Icon(Icons.terminal_outlined, size: 16), - label: const Text('Agent Command'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6366F1), - foregroundColor: Colors.white, - ), - ), - ], - ), - const SizedBox(height: 32), - const Text( - 'Domain Agents', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 12), - _buildDomainAgentsGrid(selectedEdge), - const SizedBox(height: 32), - const Text( - 'Operations Execution History', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 12), - _buildOperationsHistory(), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildDomainAgentsGrid(FleetEdgeView edge) { - final agents = edge.domainAgents; - if (agents.isEmpty) { - return Container( - padding: const EdgeInsets.all(24), - width: double.infinity, - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(12), - ), - child: const Center( - child: Text( - 'No Domain Agents reported for this Edge.', - style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13), - ), - ), - ); - } - - return LayoutBuilder( - builder: (context, constraints) { - final crossAxisCount = constraints.maxWidth > 600 ? 2 : 1; - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: agents.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - mainAxisExtent: 150, - ), - itemBuilder: (context, index) { - final agent = agents[index]; - final stateColor = _getLifecycleStateColor(agent.lifecycleState); - - return Card( - color: const Color(0xFF1E293B), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: stateColor.withValues(alpha: 0.3)), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - agent.agentKind.toUpperCase(), - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: stateColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - agent.lifecycleState.toUpperCase(), - style: TextStyle(color: stateColor, fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - agent.summary.isNotEmpty ? agent.summary : 'Available: ${agent.available}', - style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (agent.activeCommandId.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - 'Active Command: ${agent.activeCommandId}', - style: const TextStyle(color: Color(0xFF818CF8), fontSize: 11, fontFamily: 'monospace'), - overflow: TextOverflow.ellipsis, - ), - ], - const Spacer(), - ], - ), - ), - ); - }, - ); - }, - ); - } - - Widget _buildOperationsHistory() { - if (_isLoading && _operations == null) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24.0), - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), - ), - ), - ); - } - - if (_error != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Text('Error: $_error', style: const TextStyle(color: Color(0xFFEF4444))), - ), - ); - } - - final ops = _operations?.operations ?? []; - if (ops.isEmpty) { - return Container( - padding: const EdgeInsets.all(24), - width: double.infinity, - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(12), - ), - child: const Center( - child: Text( - 'No operation executions recorded.', - style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13), - ), - ), - ); - } - - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: ops.length, - itemBuilder: (context, index) { - final op = ops[index]; - final opColor = _getOpStatusColor(op.status); - - return Card( - color: const Color(0xFF1E293B), - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide(color: const Color(0xFF334155)), - ), - child: ListTile( - leading: Icon( - op.status.toLowerCase() == 'success' ? Icons.check_circle : Icons.pending, - color: opColor, - ), - title: Text(op.operation, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white)), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text( - 'ID: ${op.commandId} | Selector: ${op.targetSelector.isNotEmpty ? op.targetSelector : "all"}', - style: const TextStyle(color: Color(0xFF64748B), fontSize: 11), - ), - if (op.summary.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(op.summary, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12)), - ], - if (op.error.isNotEmpty) ...[ - const SizedBox(height: 4), - Text('Error: ${op.error}', style: const TextStyle(color: Color(0xFFEF4444), fontSize: 12)), - ], - ], - ), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: opColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - op.status.toUpperCase(), - style: TextStyle(color: opColor, fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - ), - ); - }, - ); - } - - Color _getLifecycleStateColor(String state) { - switch (state.toLowerCase()) { - case 'ready': - case 'success': - return const Color(0xFF10B981); - case 'busy': - case 'running': - return const Color(0xFFF59E0B); - case 'error': - case 'failed': - return const Color(0xFFEF4444); - case 'unknown': - default: - return const Color(0xFF94A3B8); - } - } - - Color _getOpStatusColor(String status) { - switch (status.toLowerCase()) { - case 'success': - return const Color(0xFF10B981); - case 'accepted': - case 'pending': - return const Color(0xFFF59E0B); - case 'error': - case 'failed': - return const Color(0xFFEF4444); - default: - return const Color(0xFF94A3B8); - } - } -} +export 'widgets/edges_panel.dart'; +export 'widgets/nodes_panel.dart'; +export 'widgets/execution_logs_panel.dart'; +export 'widgets/runtime_panel.dart'; diff --git a/apps/client/lib/main.dart b/apps/client/lib/main.dart index 96555e4..63f72ec 100644 --- a/apps/client/lib/main.dart +++ b/apps/client/lib/main.dart @@ -1,20 +1,12 @@ -import 'dart:async'; - -import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:iop_console/iop_console.dart'; -import 'client_config.dart'; +import 'client_bootstrap.dart'; +import 'client_home_page.dart'; import 'control_plane_status_client.dart'; -import 'control_plane_status_widgets.dart'; import 'iop_wire/client_wire_client.dart'; import 'src/integrations/mattermost/mattermost_push_host_integration.dart'; -import 'src/integrations/mattermost/mattermost_push_plugin_client.dart'; - -final _mattermostHost = MattermostPushHostIntegration( - pushClient: MattermostPushPluginClient(), -); const iopDefaultCapabilityPack = IopCapabilityPack( capabilities: [ @@ -54,18 +46,6 @@ Future applyFullscreenMode() async { ); } -Future bootstrapIopClient() async { - await applyFullscreenMode(); - await Firebase.initializeApp(); - await _mattermostHost.initialize(); -} - -Future runIopClient() async { - WidgetsFlutterBinding.ensureInitialized(); - await bootstrapIopClient(); - runApp(IopClientApp(mattermostHost: _mattermostHost)); -} - class IopClientApp extends StatelessWidget { final ClientWireClient? testClient; final MattermostPushHostIntegration? mattermostHost; @@ -100,318 +80,3 @@ class IopClientApp extends StatelessWidget { ); } } - -class ClientHomePage extends StatefulWidget { - final ClientWireClient? testClient; - final MattermostPushHostIntegration? mattermostHost; - final ControlPlaneStatusRepository? statusRepository; - - const ClientHomePage({ - super.key, - this.testClient, - this.mattermostHost, - this.statusRepository, - }); - - @override - State createState() => _ClientHomePageState(); -} - -class _ClientHomePageState extends State { - String _wireStatus = 'Disconnected'; - ClientWireClient? _client; - StreamSubscription? _notificationSubscription; - - late final ControlPlaneStatusRepository _statusRepo; - - // HTTP status states - List _edges = []; - bool _loadingEdges = false; - String? _edgesError; - String? _selectedEdgeId; - - final Map _edgeStatuses = {}; - final Map _loadingStatuses = {}; - final Map _statusErrors = {}; - - final Map> _edgeEvents = {}; - final Map _loadingEvents = {}; - final Map _eventErrors = {}; - - @override - void initState() { - super.initState(); - _statusRepo = widget.statusRepository ?? - HttpControlPlaneStatusRepository( - baseUrl: ClientConfig.controlPlaneHttpUrl, - ); - - // Auto-connect on start - WidgetsBinding.instance.addPostFrameCallback((_) { - applyFullscreenMode(); - _connectAndFetch(); - }); - - final host = widget.mattermostHost; - if (host != null) { - _notificationSubscription = host.onNotification.listen( - _showMattermostNotification, - ); - } - } - - @override - void dispose() { - _notificationSubscription?.cancel(); - _client?.close(); - super.dispose(); - } - - void _showMattermostNotification(Map data) { - if (data['type'] != 'message' || !mounted) return; - - final message = data['message'] as String? ?? ''; - final channel = data['channel_name'] as String? ?? ''; - final sender = data['sender_name'] as String? ?? ''; - final content = sender.isNotEmpty - ? '[$channel] $sender: $message' - : message; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(content, maxLines: 2, overflow: TextOverflow.ellipsis), - duration: const Duration(seconds: 4), - behavior: SnackBarBehavior.floating, - ), - ); - } - - Future _connectAndFetch() async { - await Future.wait([ - _connectWire(), - _fetchEdges(), - ]); - } - - Future _fetchEdges() async { - if (!mounted) return; - setState(() { - _loadingEdges = true; - _edgesError = null; - }); - - try { - final fleetResponse = await _statusRepo.fetchFleetStatus(); - if (!mounted) return; - setState(() { - _edges = fleetResponse.edges; - _loadingEdges = false; - - // Normalize selection: check if the previously selected edge is still present - final hasSelected = _edges.any((e) => e.edgeId == _selectedEdgeId); - if (!hasSelected) { - // Clear stale status/events/loading/error cache to prevent leak/inconsistency - if (_selectedEdgeId != null) { - _edgeStatuses.remove(_selectedEdgeId); - _loadingStatuses.remove(_selectedEdgeId); - _statusErrors.remove(_selectedEdgeId); - _edgeEvents.remove(_selectedEdgeId); - _loadingEvents.remove(_selectedEdgeId); - _eventErrors.remove(_selectedEdgeId); - } - - if (_edges.isNotEmpty) { - _selectedEdgeId = _edges.first.edgeId; - } else { - _selectedEdgeId = null; - } - } - }); - - if (_selectedEdgeId != null) { - _fetchEdgeDetails(_selectedEdgeId!); - } - } catch (e) { - if (!mounted) return; - setState(() { - _edgesError = e.toString(); - _loadingEdges = false; - }); - } - } - - Future _fetchEdgeDetails(String edgeId) async { - await Future.wait([ - _fetchEdgeStatus(edgeId), - _fetchEdgeEvents(edgeId), - ]); - } - - Future _fetchEdgeStatus(String edgeId) async { - if (!mounted) return; - setState(() { - _loadingStatuses[edgeId] = true; - _statusErrors[edgeId] = null; - }); - - try { - final status = await _statusRepo.fetchEdgeStatus(edgeId); - if (!mounted) return; - setState(() { - _edgeStatuses[edgeId] = status; - _loadingStatuses[edgeId] = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _statusErrors[edgeId] = e.toString(); - _loadingStatuses[edgeId] = false; - }); - } - } - - Future _fetchEdgeEvents(String edgeId) async { - if (!mounted) return; - setState(() { - _loadingEvents[edgeId] = true; - _eventErrors[edgeId] = null; - }); - - try { - final events = await _statusRepo.fetchEdgeEvents(edgeId); - if (!mounted) return; - setState(() { - _edgeEvents[edgeId] = events; - _loadingEvents[edgeId] = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _eventErrors[edgeId] = e.toString(); - _loadingEvents[edgeId] = false; - }); - } - } - - Future _connectWire() async { - if (!mounted) return; - setState(() { - _wireStatus = 'Connecting'; - }); - - try { - ClientWireClient client; - if (widget.testClient != null) { - client = widget.testClient!; - } else { - client = await ClientWireClient.connectToUrl( - ClientConfig.controlPlaneWireUrl, - ); - } - _client = client; - - // Hello handshake - final response = await client.hello( - clientId: 'client-ui', - clientVersion: '1.0.0', - ); - - if (!mounted) return; - setState(() { - _wireStatus = response.ready ? 'Connected' : 'Error'; - }); - - client.addDisconnectListener((_) { - if (!mounted) return; - setState(() { - _wireStatus = 'Disconnected'; - _client = null; - }); - }); - } catch (e) { - if (!mounted) return; - setState(() { - _wireStatus = 'Error'; - _client = null; - }); - } - } - - @override - Widget build(BuildContext context) { - final config = IopConsoleConfig( - controlPlaneHttpUrl: ClientConfig.controlPlaneHttpUrl, - controlPlaneWireUrl: ClientConfig.controlPlaneWireUrl, - ); - - final currentEdgeId = _selectedEdgeId; - - return IopConsoleShell( - config: config, - capabilities: iopDefaultCapabilityPack, - overview: IopConsoleOverview( - config: config, - statusText: _wireStatus, - onRefresh: _connectAndFetch, - ), - edges: EdgesPanel( - edges: _edges, - isLoading: _loadingEdges, - error: _edgesError, - selectedEdgeId: currentEdgeId, - onSelectEdge: (edgeId) { - setState(() { - _selectedEdgeId = edgeId; - }); - _fetchEdgeDetails(edgeId); - }, - onRefresh: _fetchEdges, - ), - nodes: NodesPanel( - edges: _edges, - selectedEdgeId: currentEdgeId, - selectedEdgeStatus: currentEdgeId != null ? _edgeStatuses[currentEdgeId] : null, - isLoading: currentEdgeId != null ? (_loadingStatuses[currentEdgeId] ?? false) : false, - error: currentEdgeId != null ? _statusErrors[currentEdgeId] : null, - onSelectEdge: (edgeId) { - setState(() { - _selectedEdgeId = edgeId; - }); - _fetchEdgeDetails(edgeId); - }, - onRefresh: () { - if (currentEdgeId != null) _fetchEdgeStatus(currentEdgeId); - }, - ), - runtime: RuntimePanel( - edges: _edges, - selectedEdgeId: currentEdgeId, - onSelectEdge: (edgeId) { - setState(() { - _selectedEdgeId = edgeId; - }); - _fetchEdgeDetails(edgeId); - }, - statusRepository: _statusRepo, - ), - executionLogs: ExecutionLogsPanel( - edges: _edges, - selectedEdgeId: currentEdgeId, - events: currentEdgeId != null ? (_edgeEvents[currentEdgeId] ?? []) : [], - isLoading: currentEdgeId != null ? (_loadingEvents[currentEdgeId] ?? false) : false, - error: currentEdgeId != null ? _eventErrors[currentEdgeId] : null, - onSelectEdge: (edgeId) { - setState(() { - _selectedEdgeId = edgeId; - }); - _fetchEdgeDetails(edgeId); - }, - onRefresh: () { - if (currentEdgeId != null) _fetchEdgeEvents(currentEdgeId); - }, - ), - agent: const IopAgentPanel(capabilities: iopDefaultCapabilityPack), - ); - } - -} diff --git a/apps/client/lib/widgets/edges_panel.dart b/apps/client/lib/widgets/edges_panel.dart new file mode 100644 index 0000000..119b896 --- /dev/null +++ b/apps/client/lib/widgets/edges_panel.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import '../control_plane_status_client.dart'; + +class EdgesPanel extends StatelessWidget { + final List edges; + final bool isLoading; + final String? error; + final String? selectedEdgeId; + final ValueChanged onSelectEdge; + final VoidCallback onRefresh; + + const EdgesPanel({ + super.key, + required this.edges, + required this.isLoading, + required this.error, + required this.selectedEdgeId, + required this.onSelectEdge, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + if (isLoading && edges.isEmpty) { + return const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), + ), + ); + } + + if (error != null && edges.isEmpty) { + return Center( + child: Container( + padding: const EdgeInsets.all(24), + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFEF4444).withValues(alpha: 0.3)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 48), + const SizedBox(height: 16), + const Text( + 'Failed to Load Edges', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(height: 8), + Text( + error!, + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: onRefresh, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ); + } + + if (edges.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.dns_outlined, color: Color(0xFF6366F1), size: 64), + const SizedBox(height: 16), + const Text( + 'No Edges Registered', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(height: 8), + const Text( + 'Connect your edges to the Control Plane to see them here.', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + OutlinedButton.icon( + onPressed: onRefresh, + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF6366F1), + side: const BorderSide(color: Color(0xFF6366F1)), + ), + ), + ], + ), + ); + } + + final selectedEdge = edges.firstWhere( + (e) => e.edgeId == selectedEdgeId, + orElse: () => edges.first, + ); + + Widget buildEdgeList(bool isMobile) { + return Container( + decoration: BoxDecoration( + border: isMobile + ? const Border(bottom: BorderSide(color: Color(0xFF334155))) + : const Border(right: BorderSide(color: Color(0xFF334155))), + ), + child: ListView.builder( + itemCount: edges.length, + itemBuilder: (context, index) { + final edge = edges[index]; + final isSelected = edge.edgeId == selectedEdge.edgeId; + return InkWell( + onTap: () => onSelectEdge(edge.edgeId), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF1E293B) : Colors.transparent, + border: const Border( + bottom: BorderSide(color: Color(0xFF334155), width: 0.5), + ), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: edge.connected ? const Color(0xFF10B981) : const Color(0xFF64748B), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + edge.edgeName.isNotEmpty ? edge.edgeName : edge.edgeId, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? const Color(0xFF6366F1) : Colors.white, + fontSize: 15, + ), + ), + const SizedBox(height: 4), + Text( + 'ID: ${edge.edgeId}', + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF64748B), size: 16), + ], + ), + ), + ); + }, + ), + ); + } + + Widget buildEdgeDetails(bool isMobile) { + return Container( + padding: EdgeInsets.all(isMobile ? 16 : 24), + child: ListView( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + selectedEdge.edgeName.isNotEmpty ? selectedEdge.edgeName : selectedEdge.edgeId, + style: TextStyle(fontSize: isMobile ? 18 : 22, fontWeight: FontWeight.bold, color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: selectedEdge.connected + ? const Color(0xFF10B981).withValues(alpha: 0.15) + : const Color(0xFF64748B).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selectedEdge.connected ? const Color(0xFF10B981) : const Color(0xFF64748B), + width: 1.5, + ), + ), + child: Text( + selectedEdge.connected ? 'CONNECTED' : 'DISCONNECTED', + style: TextStyle( + color: selectedEdge.connected ? const Color(0xFF10B981) : const Color(0xFF94A3B8), + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + _buildDetailRow('Edge ID', selectedEdge.edgeId), + _buildDetailRow('Version', selectedEdge.version), + _buildDetailRow('Protocol', selectedEdge.protocol), + _buildDetailRow('Last Seen', selectedEdge.lastSeen.toLocal().toString()), + _buildDetailRow('Fleet Health', selectedEdge.health), + _buildDetailRow('Node Count', selectedEdge.nodeCount.toString()), + const SizedBox(height: 24), + const Text( + 'Capabilities', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(height: 12), + if (selectedEdge.capabilities.isEmpty) + const Text('No capabilities reported', style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13)) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: selectedEdge.capabilities.map((cap) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF6366F1).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFF6366F1).withValues(alpha: 0.3)), + ), + child: Text( + cap.kind.isNotEmpty ? cap.kind : cap.toString(), + style: const TextStyle(color: Color(0xFF818CF8), fontSize: 12, fontWeight: FontWeight.w500), + ), + ); + }).toList(), + ), + ], + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + if (isMobile) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 2, + child: buildEdgeList(true), + ), + Expanded( + flex: 3, + child: buildEdgeDetails(true), + ), + ], + ); + } else { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 2, + child: buildEdgeList(false), + ), + Expanded( + flex: 3, + child: buildEdgeDetails(false), + ), + ], + ); + } + }, + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12)), + const SizedBox(height: 4), + Text( + value.isNotEmpty ? value : '-', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + const Divider(color: Color(0xFF334155), height: 1), + ], + ), + ); + } +} diff --git a/apps/client/lib/widgets/execution_logs_panel.dart b/apps/client/lib/widgets/execution_logs_panel.dart new file mode 100644 index 0000000..6b68a6d --- /dev/null +++ b/apps/client/lib/widgets/execution_logs_panel.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import '../control_plane_status_client.dart'; + +class ExecutionLogsPanel extends StatelessWidget { + final List edges; + final String? selectedEdgeId; + final List events; + final bool isLoading; + final String? error; + final ValueChanged onSelectEdge; + final VoidCallback onRefresh; + + const ExecutionLogsPanel({ + super.key, + required this.edges, + required this.selectedEdgeId, + required this.events, + required this.isLoading, + required this.error, + required this.onSelectEdge, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + if (edges.isEmpty) { + return const Center( + child: Text( + 'Please connect and register at least one Edge first.', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), + ), + ); + } + + final currentEdgeId = selectedEdgeId ?? edges.first.edgeId; + final hasCurrentEdge = edges.any((e) => e.edgeId == currentEdgeId); + final dropdownValue = hasCurrentEdge ? currentEdgeId : (edges.isNotEmpty ? edges.first.edgeId : null); + + return Container( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Lifecycle Events & Logs', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 16), + if (edges.isNotEmpty && dropdownValue != null) ...[ + const Text('Filter Edge: ', style: TextStyle(color: Color(0xFF94A3B8))), + const SizedBox(width: 8), + DropdownButton( + value: dropdownValue, + dropdownColor: const Color(0xFF1E293B), + style: const TextStyle(color: Colors.white), + underline: const SizedBox(), + items: edges.map((e) { + return DropdownMenuItem( + value: e.edgeId, + child: Text(e.edgeName.isNotEmpty ? e.edgeName : e.edgeId), + ); + }).toList(), + onChanged: (val) { + if (val != null) onSelectEdge(val); + }, + ), + ], + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.refresh, color: Color(0xFF6366F1)), + onPressed: onRefresh, + ), + ], + ), + const SizedBox(height: 20), + Expanded(child: _buildLogsContent()), + ], + ), + ); + } + + Widget _buildLogsContent() { + if (isLoading) { + return const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), + ), + ); + } + + if (error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 48), + SizedBox(height: 16), + const Text( + 'Failed to Load Events', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(height: 8), + Text(error!, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13)), + ], + ), + ); + } + + if (events.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history_toggle_off, color: Color(0xFF64748B), size: 56), + SizedBox(height: 16), + Text( + 'No Events Captured', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + SizedBox(height: 4), + Text( + 'No lifecycle events have been recorded for this edge yet.', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13), + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: events.length, + itemBuilder: (context, index) { + final event = events[index]; + final eventColor = _getEventColor(event.type); + final eventIcon = _getEventIcon(event.type); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(8), + border: Border( + left: BorderSide(color: eventColor, width: 4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(eventIcon, size: 16, color: eventColor), + const SizedBox(width: 8), + Text( + event.type.toUpperCase(), + style: TextStyle(color: eventColor, fontSize: 12, fontWeight: FontWeight.bold), + ), + const Spacer(), + Text( + event.timestamp.toLocal().toString().substring(11, 19), + style: const TextStyle(color: Color(0xFF64748B), fontSize: 12), + ), + ], + ), + const SizedBox(height: 8), + Text( + event.reason, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Row( + children: [ + const Text('Node: ', style: TextStyle(color: Color(0xFF64748B), fontSize: 12)), + Text( + event.alias.isNotEmpty ? event.alias : (event.nodeId.isNotEmpty ? event.nodeId : 'Edge System'), + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), + ), + if (event.source.isNotEmpty) ...[ + const SizedBox(width: 12), + const Text('Source: ', style: TextStyle(color: Color(0xFF64748B), fontSize: 12)), + Text( + event.source, + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), + ), + ], + ], + ), + ], + ), + ); + }, + ); + } + + Color _getEventColor(String type) { + switch (type.toLowerCase()) { + case 'error': + case 'failed': + return const Color(0xFFEF4444); + case 'warning': + case 'warn': + return const Color(0xFFF59E0B); + case 'success': + case 'connected': + case 'online': + return const Color(0xFF10B981); + case 'info': + default: + return const Color(0xFF6366F1); + } + } + + IconData _getEventIcon(String type) { + switch (type.toLowerCase()) { + case 'error': + case 'failed': + return Icons.error_outline; + case 'warning': + case 'warn': + return Icons.warning_amber_outlined; + case 'success': + case 'connected': + case 'online': + return Icons.check_circle_outline; + case 'info': + default: + return Icons.info_outline; + } + } +} diff --git a/apps/client/lib/widgets/nodes_panel.dart b/apps/client/lib/widgets/nodes_panel.dart new file mode 100644 index 0000000..5a9cee9 --- /dev/null +++ b/apps/client/lib/widgets/nodes_panel.dart @@ -0,0 +1,280 @@ +import 'package:flutter/material.dart'; +import '../control_plane_status_client.dart'; + +class NodesPanel extends StatelessWidget { + final List edges; + final String? selectedEdgeId; + final EdgeStatusResponseView? selectedEdgeStatus; + final bool isLoading; + final String? error; + final ValueChanged onSelectEdge; + final VoidCallback onRefresh; + + const NodesPanel({ + super.key, + required this.edges, + required this.selectedEdgeId, + required this.selectedEdgeStatus, + required this.isLoading, + required this.error, + required this.onSelectEdge, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + if (edges.isEmpty) { + return const Center( + child: Text( + 'Please connect and register at least one Edge first.', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), + ), + ); + } + + final currentEdgeId = selectedEdgeId ?? edges.first.edgeId; + final hasCurrentEdge = edges.any((e) => e.edgeId == currentEdgeId); + final dropdownValue = hasCurrentEdge ? currentEdgeId : (edges.isNotEmpty ? edges.first.edgeId : null); + + return Container( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'Nodes View', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 16), + if (edges.isNotEmpty && dropdownValue != null) ...[ + const Text('Active Edge: ', style: TextStyle(color: Color(0xFF94A3B8))), + const SizedBox(width: 8), + DropdownButton( + value: dropdownValue, + dropdownColor: const Color(0xFF1E293B), + style: const TextStyle(color: Colors.white), + underline: const SizedBox(), + items: edges.map((e) { + return DropdownMenuItem( + value: e.edgeId, + child: Text(e.edgeName.isNotEmpty ? e.edgeName : e.edgeId), + ); + }).toList(), + onChanged: (val) { + if (val != null) onSelectEdge(val); + }, + ), + ], + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.refresh, color: Color(0xFF6366F1)), + onPressed: onRefresh, + ), + ], + ), + const SizedBox(height: 20), + Expanded(child: _buildNodeContent()), + ], + ), + ); + } + + Widget _buildNodeContent() { + if (isLoading) { + return const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), + ), + ); + } + + if (error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 48), + SizedBox(height: 16), + const Text( + 'Failed to Load Nodes Status', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(height: 8), + Text(error!, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13)), + ], + ), + ); + } + + final nodes = selectedEdgeStatus?.nodes ?? []; + if (nodes.isEmpty) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.developer_board_off_outlined, color: Color(0xFF64748B), size: 56), + SizedBox(height: 16), + Text( + 'No Nodes Registered', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + SizedBox(height: 4), + Text( + 'No active nodes have registered on this edge.', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13), + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: nodes.length, + itemBuilder: (context, index) { + final node = nodes[index]; + return Card( + color: const Color(0xFF1E293B), + margin: const EdgeInsets.only(bottom: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: node.connected + ? const Color(0xFF10B981).withValues(alpha: 0.3) + : const Color(0xFF334155), + ), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + node.alias.isNotEmpty ? node.alias : node.nodeId, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'ID: ${node.nodeId}', + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: node.connected + ? const Color(0xFF10B981).withValues(alpha: 0.1) + : const Color(0xFFEF4444).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + node.connected ? 'CONNECTED' : 'OFFLINE', + style: TextStyle( + color: node.connected ? const Color(0xFF10B981) : const Color(0xFFEF4444), + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + if (node.label.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF334155), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Label: ${node.label}', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ], + if (node.config != null) ...[ + const SizedBox(height: 16), + const Divider(color: Color(0xFF334155)), + const SizedBox(height: 12), + Row( + children: [ + const Icon(Icons.bolt, size: 16, color: Color(0xFF6366F1)), + const SizedBox(width: 6), + Text( + 'Concurrency Limit: ${node.config!.concurrency}', + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'Adapters', + style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + if (node.config!.adapters.isEmpty) + const Text('No active adapters configured', style: TextStyle(color: Color(0xFF94A3B8), fontSize: 12)) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: node.config!.adapters.map((adapter) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: adapter.enabled + ? const Color(0xFF10B981).withValues(alpha: 0.1) + : const Color(0xFFEF4444).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: adapter.enabled ? const Color(0xFF10B981) : const Color(0xFFEF4444), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + adapter.type, + style: TextStyle( + color: adapter.enabled ? const Color(0xFF10B981) : const Color(0xFFEF4444), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Icon( + adapter.enabled ? Icons.check : Icons.close, + size: 12, + color: adapter.enabled ? const Color(0xFF10B981) : const Color(0xFFEF4444), + ), + ], + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/apps/client/lib/widgets/runtime_panel.dart b/apps/client/lib/widgets/runtime_panel.dart new file mode 100644 index 0000000..94338aa --- /dev/null +++ b/apps/client/lib/widgets/runtime_panel.dart @@ -0,0 +1,693 @@ +import 'package:flutter/material.dart'; +import '../control_plane_status_client.dart'; + +class RuntimePanel extends StatefulWidget { + final List edges; + final String? selectedEdgeId; + final ValueChanged onSelectEdge; + final ControlPlaneStatusRepository statusRepository; + + const RuntimePanel({ + super.key, + required this.edges, + required this.selectedEdgeId, + required this.onSelectEdge, + required this.statusRepository, + }); + + @override + State createState() => _RuntimePanelState(); +} + +class _RuntimePanelState extends State { + bool _isLoading = false; + String? _error; + EdgeOperationsResponseView? _operations; + String? _lastCommandStatus; + + @override + void initState() { + super.initState(); + _fetchOperations(); + } + + @override + void didUpdateWidget(RuntimePanel oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedEdgeId != widget.selectedEdgeId) { + _fetchOperations(); + } + } + + Future _fetchOperations() async { + final edgeId = widget.selectedEdgeId; + if (edgeId == null) return; + setState(() { + _isLoading = true; + _error = null; + _operations = null; + }); + try { + final ops = await widget.statusRepository.fetchEdgeOperations(edgeId); + if (mounted && widget.selectedEdgeId == edgeId) { + setState(() { + _operations = ops; + _isLoading = false; + }); + } + } catch (e) { + if (mounted && widget.selectedEdgeId == edgeId) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + Future _sendCommand(String operation) async { + final edgeId = widget.selectedEdgeId; + if (edgeId == null) return; + setState(() { + _isLoading = true; + _lastCommandStatus = null; + }); + try { + final resp = await widget.statusRepository.sendEdgeCommand(edgeId, operation); + if (mounted) { + final isError = resp.status.toLowerCase() == 'error' || + resp.status.toLowerCase() == 'failed' || + resp.status.toLowerCase() == 'unsupported' || + resp.error.isNotEmpty; + setState(() { + if (isError) { + _lastCommandStatus = 'Failed: ${resp.status} - ${resp.error.isNotEmpty ? resp.error : resp.summary}'; + } else { + _lastCommandStatus = 'Success: Command accepted (${resp.status}) - ID: ${resp.commandId}'; + } + _isLoading = false; + }); + _fetchOperations(); + } + } catch (e) { + if (mounted) { + setState(() { + _lastCommandStatus = 'Failed: ${e.toString()}'; + _isLoading = false; + }); + } + } + } + + void _showAgentCommandDialog() { + final selectorController = TextEditingController(); + final commandController = TextEditingController(); + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: const Color(0xFF1E293B), + title: const Text('Send Agent Command', style: TextStyle(color: Colors.white)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: selectorController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Target Selector (e.g. node-1)', + labelStyle: TextStyle(color: Color(0xFF94A3B8)), + ), + ), + const SizedBox(height: 12), + TextField( + controller: commandController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Command Name (parameters.command)', + labelStyle: TextStyle(color: Color(0xFF94A3B8)), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel', style: TextStyle(color: Color(0xFF94A3B8))), + ), + ElevatedButton( + onPressed: () { + final selector = selectorController.text.trim(); + final cmd = commandController.text.trim(); + if (selector.isNotEmpty && cmd.isNotEmpty) { + Navigator.pop(context); + _sendAgentCommand(selector, cmd); + } + }, + child: const Text('Send'), + ), + ], + ); + }, + ); + } + + Future _sendAgentCommand(String selector, String command) async { + final edgeId = widget.selectedEdgeId; + if (edgeId == null) return; + if (selector.isEmpty || command.isEmpty) return; + setState(() { + _isLoading = true; + _lastCommandStatus = null; + }); + try { + final resp = await widget.statusRepository.sendEdgeCommand( + edgeId, + 'agent.command', + targetSelector: selector, + parameters: {'command': command}, + ); + if (mounted) { + final isError = resp.status.toLowerCase() == 'error' || + resp.status.toLowerCase() == 'failed' || + resp.status.toLowerCase() == 'unsupported' || + resp.error.isNotEmpty; + setState(() { + if (isError) { + _lastCommandStatus = 'Failed: ${resp.status} - ${resp.error.isNotEmpty ? resp.error : resp.summary}'; + } else { + _lastCommandStatus = 'Success: Command accepted (${resp.status}) - ID: ${resp.commandId}'; + } + _isLoading = false; + }); + _fetchOperations(); + } + } catch (e) { + if (mounted) { + setState(() { + _lastCommandStatus = 'Failed: ${e.toString()}'; + _isLoading = false; + }); + } + } + } + + void _showAgentStatusDialog() { + final selectorController = TextEditingController(); + + showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: const Color(0xFF1E293B), + title: const Text('Get Agent Status', style: TextStyle(color: Colors.white)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: selectorController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Target Selector (e.g. node-1)', + labelStyle: TextStyle(color: Color(0xFF94A3B8)), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel', style: TextStyle(color: Color(0xFF94A3B8))), + ), + ElevatedButton( + onPressed: () { + final selector = selectorController.text.trim(); + if (selector.isNotEmpty) { + Navigator.pop(context); + _sendAgentStatus(selector); + } + }, + child: const Text('Send'), + ), + ], + ); + }, + ); + } + + Future _sendAgentStatus(String selector) async { + final edgeId = widget.selectedEdgeId; + if (edgeId == null) return; + if (selector.isEmpty) return; + setState(() { + _isLoading = true; + _lastCommandStatus = null; + }); + try { + final resp = await widget.statusRepository.sendEdgeCommand( + edgeId, + 'agent.status', + targetSelector: selector, + ); + if (mounted) { + final isError = resp.status.toLowerCase() == 'error' || + resp.status.toLowerCase() == 'failed' || + resp.status.toLowerCase() == 'unsupported' || + resp.error.isNotEmpty; + setState(() { + if (isError) { + _lastCommandStatus = 'Failed: ${resp.status} - ${resp.error.isNotEmpty ? resp.error : resp.summary}'; + } else { + _lastCommandStatus = 'Success: Command accepted (${resp.status}) - ID: ${resp.commandId}'; + } + _isLoading = false; + }); + _fetchOperations(); + } + } catch (e) { + if (mounted) { + setState(() { + _lastCommandStatus = 'Failed: ${e.toString()}'; + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + if (widget.edges.isEmpty) { + return const Center( + child: Text( + 'Please connect and register at least one Edge first.', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14), + ), + ); + } + + final currentEdgeId = widget.selectedEdgeId ?? widget.edges.first.edgeId; + final hasCurrentEdge = widget.edges.any((e) => e.edgeId == currentEdgeId); + final dropdownValue = hasCurrentEdge ? currentEdgeId : (widget.edges.isNotEmpty ? widget.edges.first.edgeId : null); + final selectedEdge = hasCurrentEdge ? widget.edges.firstWhere((e) => e.edgeId == currentEdgeId) : widget.edges.first; + + + return Container( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LayoutBuilder( + builder: (context, headerConstraints) { + final isNarrow = headerConstraints.maxWidth < 500; + final headerTitle = const Text( + 'Operations & Domain Agents', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white), + overflow: TextOverflow.ellipsis, + ); + final headerControls = Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + if (widget.edges.isNotEmpty && dropdownValue != null) ...[ + const Text('Active Edge: ', style: TextStyle(color: Color(0xFF94A3B8))), + DropdownButton( + value: dropdownValue, + dropdownColor: const Color(0xFF1E293B), + style: const TextStyle(color: Colors.white), + underline: const SizedBox(), + items: widget.edges.map((e) { + return DropdownMenuItem( + value: e.edgeId, + child: Text(e.edgeName.isNotEmpty ? e.edgeName : e.edgeId), + ); + }).toList(), + onChanged: (val) { + if (val != null) widget.onSelectEdge(val); + }, + ), + ], + IconButton( + icon: const Icon(Icons.refresh, color: Color(0xFF6366F1)), + onPressed: _fetchOperations, + ), + ], + ); + + if (isNarrow) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + headerTitle, + const SizedBox(height: 12), + headerControls, + ], + ); + } else { + return Row( + children: [ + Expanded(child: headerTitle), + const SizedBox(width: 16), + headerControls, + ], + ); + } + }, + ), + const SizedBox(height: 24), + if (_lastCommandStatus != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: _lastCommandStatus!.startsWith('Success') + ? const Color(0xFF10B981).withValues(alpha: 0.1) + : const Color(0xFFEF4444).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _lastCommandStatus!.startsWith('Success') + ? const Color(0xFF10B981).withValues(alpha: 0.3) + : const Color(0xFFEF4444).withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + _lastCommandStatus!.startsWith('Success') ? Icons.check_circle_outline : Icons.error_outline, + color: _lastCommandStatus!.startsWith('Success') ? const Color(0xFF10B981) : const Color(0xFFEF4444), + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _lastCommandStatus!, + style: TextStyle( + color: _lastCommandStatus!.startsWith('Success') ? const Color(0xFFD1FAE5) : const Color(0xFFFEE2E2), + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 16, color: Color(0xFF94A3B8)), + onPressed: () { + setState(() { + _lastCommandStatus = null; + }); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + ], + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'System Gated Operations', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton.icon( + onPressed: _isLoading ? null : () => _sendCommand('health.check'), + icon: const Icon(Icons.health_and_safety_outlined, size: 16), + label: const Text('Health Check'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + ), + ), + ElevatedButton.icon( + onPressed: _isLoading ? null : _showAgentStatusDialog, + icon: const Icon(Icons.info_outline, size: 16), + label: const Text('Agent Status'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF3B82F6), + foregroundColor: Colors.white, + ), + ), + ElevatedButton.icon( + onPressed: _isLoading ? null : _showAgentCommandDialog, + icon: const Icon(Icons.terminal_outlined, size: 16), + label: const Text('Agent Command'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + foregroundColor: Colors.white, + ), + ), + ], + ), + const SizedBox(height: 32), + const Text( + 'Domain Agents', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(height: 12), + _buildDomainAgentsGrid(selectedEdge), + const SizedBox(height: 32), + const Text( + 'Operations Execution History', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + const SizedBox(height: 12), + _buildOperationsHistory(), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildDomainAgentsGrid(FleetEdgeView edge) { + final agents = edge.domainAgents; + if (agents.isEmpty) { + return Container( + padding: const EdgeInsets.all(24), + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + 'No Domain Agents reported for this Edge.', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13), + ), + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = constraints.maxWidth > 600 ? 2 : 1; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: agents.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + mainAxisExtent: 150, + ), + itemBuilder: (context, index) { + final agent = agents[index]; + final stateColor = _getLifecycleStateColor(agent.lifecycleState); + + return Card( + color: const Color(0xFF1E293B), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: stateColor.withValues(alpha: 0.3)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + agent.agentKind.toUpperCase(), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: stateColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + agent.lifecycleState.toUpperCase(), + style: TextStyle(color: stateColor, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + agent.summary.isNotEmpty ? agent.summary : 'Available: ${agent.available}', + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (agent.activeCommandId.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + 'Active Command: ${agent.activeCommandId}', + style: const TextStyle(color: Color(0xFF818CF8), fontSize: 11, fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + ], + const Spacer(), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Widget _buildOperationsHistory() { + if (_isLoading && _operations == null) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24.0), + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFF6366F1)), + ), + ), + ); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text('Error: $_error', style: const TextStyle(color: Color(0xFFEF4444))), + ), + ); + } + + final ops = _operations?.operations ?? []; + if (ops.isEmpty) { + return Container( + padding: const EdgeInsets.all(24), + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + 'No operation executions recorded.', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13), + ), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: ops.length, + itemBuilder: (context, index) { + final op = ops[index]; + final opColor = _getOpStatusColor(op.status); + + return Card( + color: const Color(0xFF1E293B), + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: const Color(0xFF334155)), + ), + child: ListTile( + leading: Icon( + op.status.toLowerCase() == 'success' ? Icons.check_circle : Icons.pending, + color: opColor, + ), + title: Text(op.operation, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + 'ID: ${op.commandId} | Selector: ${op.targetSelector.isNotEmpty ? op.targetSelector : "all"}', + style: const TextStyle(color: Color(0xFF64748B), fontSize: 11), + ), + if (op.summary.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(op.summary, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12)), + ], + if (op.error.isNotEmpty) ...[ + const SizedBox(height: 4), + Text('Error: ${op.error}', style: const TextStyle(color: Color(0xFFEF4444), fontSize: 12)), + ], + ], + ), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: opColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + op.status.toUpperCase(), + style: TextStyle(color: opColor, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ), + ); + }, + ); + } + + Color _getLifecycleStateColor(String state) { + switch (state.toLowerCase()) { + case 'ready': + case 'success': + return const Color(0xFF10B981); + case 'busy': + case 'running': + return const Color(0xFFF59E0B); + case 'error': + case 'failed': + return const Color(0xFFEF4444); + case 'unknown': + default: + return const Color(0xFF94A3B8); + } + } + + Color _getOpStatusColor(String status) { + switch (status.toLowerCase()) { + case 'success': + return const Color(0xFF10B981); + case 'accepted': + case 'pending': + return const Color(0xFFF59E0B); + case 'error': + case 'failed': + return const Color(0xFFEF4444); + default: + return const Color(0xFF94A3B8); + } + } +} diff --git a/apps/client/test/client_bootstrap_test.dart b/apps/client/test/client_bootstrap_test.dart new file mode 100644 index 0000000..d0a1841 --- /dev/null +++ b/apps/client/test/client_bootstrap_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:iop_client/client_bootstrap.dart'; +import 'package:iop_client/main.dart'; +import 'widget_test.dart'; + +void main() { + testWidgets('runIopClient can skip external integrations', (WidgetTester tester) async { + const options = IopClientBootstrapOptions( + initializeFirebase: false, + initializeMattermost: false, + ); + + // This should run without throwing any Firebase or Mattermost exceptions + await bootstrapIopClient(options: options); + + final fakeClient = FakeClientWireClient(shouldSuccess: true); + + // Verify it doesn't fail basic widget pump + await tester.pumpWidget( + IopClientApp( + testClient: fakeClient, + statusRepository: null, + ), + ); + expect(find.byType(IopClientApp), findsOneWidget); + }); +} diff --git a/apps/client/test/control_plane_status_controller_test.dart b/apps/client/test/control_plane_status_controller_test.dart new file mode 100644 index 0000000..a667dc8 --- /dev/null +++ b/apps/client/test/control_plane_status_controller_test.dart @@ -0,0 +1,244 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:iop_client/control_plane_status_controller.dart'; +import 'package:iop_client/control_plane_status_client.dart'; +import 'widget_test.dart'; + +class ControllableStatusRepository implements ControlPlaneStatusRepository { + final fleetCompleter = Completer(); + final statusCompleter = Completer(); + final eventsCompleter = Completer>(); + + int fleetCallCount = 0; + int statusCallCount = 0; + int eventsCallCount = 0; + + @override + Future fetchFleetStatus() { + fleetCallCount += 1; + return fleetCompleter.future; + } + + @override + Future fetchEdgeStatus(String edgeId) { + statusCallCount += 1; + return statusCompleter.future; + } + + @override + Future> fetchEdgeEvents(String edgeId) { + eventsCallCount += 1; + return eventsCompleter.future; + } + + @override + Future> fetchEdges() async => []; + + @override + Future fetchEdgeOperations(String edgeId) async { + return EdgeOperationsResponseView(edgeId: edgeId, operations: []); + } + + @override + Future sendEdgeCommand( + String edgeId, + String operation, { + String? targetSelector, + Map? parameters, + }) async { + return EdgeCommandResponseView( + requestId: 'req-1', + commandId: 'cmd-1', + edgeId: edgeId, + status: 'success', + summary: '', + error: '', + ); + } +} + +void main() { + test( + 'removes stale selected edge caches when fleet refresh drops edge', + () async { + final fakeStatusRepo = FakeControlPlaneStatusRepository(); + final controller = ControlPlaneStatusController( + statusRepository: fakeStatusRepo, + ); + + // Initial fetch (edge-a and edge-b) + await controller.fetchEdges(); + expect(controller.edges.length, 2); + expect(controller.selectedEdgeId, 'edge-a'); + + // Select edge-b and fetch details + controller.selectEdge('edge-b'); + await controller.fetchEdgeDetails('edge-b'); + + expect(controller.selectedEdgeId, 'edge-b'); + expect(controller.edgeStatuses.containsKey('edge-b'), true); + expect(controller.edgeEvents.containsKey('edge-b'), true); + + // Drop edge-b from mock repository + fakeStatusRepo.mockEdges = [ + EdgeRegistryView( + edgeId: 'edge-a', + edgeName: 'Edge Alpha', + version: '1.2.0', + capabilities: ['Serving', 'Automation'], + protocol: 'iop-wire-v1', + connected: true, + lastSeen: DateTime.now(), + ), + ]; + + // Refresh fleet status + await controller.fetchEdges(); + + // Verify selection falls back to edge-a and edge-b's cache is removed + expect(controller.selectedEdgeId, 'edge-a'); + expect(controller.edgeStatuses.containsKey('edge-b'), false); + expect(controller.edgeEvents.containsKey('edge-b'), false); + }, + ); + + test('ignores in-flight fetch completions after dispose', () async { + final fakeRepo = ControllableStatusRepository(); + final controller = ControlPlaneStatusController(statusRepository: fakeRepo); + + // Start fetching edges + final fetchFuture = controller.fetchEdges(); + + // Verify loading state is true + expect(controller.loadingEdges, true); + + // Dispose controller + controller.dispose(); + + // Complete the future + fakeRepo.fleetCompleter.complete( + FleetStatusResponseView( + generatedAt: DateTime.now(), + edges: [ + FleetEdgeView( + edgeId: 'edge-a', + edgeName: 'Edge Alpha', + version: '1.2.0', + protocol: 'iop-wire-v1', + connected: true, + health: 'healthy', + lastSeen: DateTime.now(), + nodeCount: 1, + capabilities: [], + domainAgents: [], + error: '', + ), + ], + ), + ); + + // Await the future completion + await fetchFuture; + + // Verify state was NOT updated (should remain default/initial values) + expect(controller.edges.isEmpty, true); + expect( + controller.loadingEdges, + true, + ); // Loading edges is still true because mutation didn't happen after dispose + }); + + test('ignores in-flight fetch errors after dispose', () async { + final fakeRepo = ControllableStatusRepository(); + final controller = ControlPlaneStatusController(statusRepository: fakeRepo); + + final fetchFuture = controller.fetchEdges(); + + expect(controller.loadingEdges, true); + + controller.dispose(); + + fakeRepo.fleetCompleter.completeError(StateError('fleet failed')); + + await expectLater(fetchFuture, completes); + + expect(controller.edges.isEmpty, true); + expect(controller.edgesError, isNull); + expect(controller.loadingEdges, true); + }); + + test('ignores status and events fetch completions after dispose', () async { + final fakeRepo = ControllableStatusRepository(); + final controller = ControlPlaneStatusController(statusRepository: fakeRepo); + + // Start fetching details + final detailsFuture = controller.fetchEdgeDetails('edge-a'); + + // Verify loading status is set to true + expect(controller.loadingStatuses['edge-a'], true); + expect(controller.loadingEvents['edge-a'], true); + + // Dispose controller + controller.dispose(); + + // Complete the status and events + fakeRepo.statusCompleter.complete( + EdgeStatusResponseView( + requestId: 'req-1', + edgeId: 'edge-a', + edgeName: 'Edge Alpha', + observedTimeUnixNano: 0, + nodes: [], + capabilities: [], + domainAgents: [], + metadata: {}, + error: '', + ), + ); + + fakeRepo.eventsCompleter.complete([]); + + // Await the future completion + await detailsFuture; + + // Verify state was NOT updated + expect(controller.edgeStatuses.containsKey('edge-a'), false); + expect(controller.edgeEvents.containsKey('edge-a'), false); + }); + + test('ignores status and events fetch errors after dispose', () async { + final fakeRepo = ControllableStatusRepository(); + final controller = ControlPlaneStatusController(statusRepository: fakeRepo); + + final detailsFuture = controller.fetchEdgeDetails('edge-a'); + + expect(controller.loadingStatuses['edge-a'], true); + expect(controller.loadingEvents['edge-a'], true); + + controller.dispose(); + + fakeRepo.statusCompleter.completeError(StateError('status failed')); + fakeRepo.eventsCompleter.completeError(StateError('events failed')); + + await expectLater(detailsFuture, completes); + + expect(controller.edgeStatuses.containsKey('edge-a'), false); + expect(controller.edgeEvents.containsKey('edge-a'), false); + expect(controller.statusErrors['edge-a'], isNull); + expect(controller.eventErrors['edge-a'], isNull); + expect(controller.loadingStatuses['edge-a'], true); + expect(controller.loadingEvents['edge-a'], true); + }); + + test('ignores selectEdge after dispose', () { + final fakeRepo = ControllableStatusRepository(); + final controller = ControlPlaneStatusController(statusRepository: fakeRepo); + + controller.dispose(); + controller.selectEdge('edge-a'); + + expect(controller.selectedEdgeId, isNull); + expect(fakeRepo.statusCallCount, 0); + expect(fakeRepo.eventsCallCount, 0); + }); +} diff --git a/apps/client/test/widget_test.dart b/apps/client/test/widget_test.dart index 4c22e56..75b4226 100644 --- a/apps/client/test/widget_test.dart +++ b/apps/client/test/widget_test.dart @@ -41,7 +41,7 @@ class FakeClientWireClient extends ClientWireClient { FakeClientWireClient({ this.shouldSuccess = true, this.mockMessage = 'Welcome to Toki CP!', - }) : super(FakeWebSocket()); + }) : super(FakeWebSocket(), heartbeatIntervalTime: 0); @override bool get isAlive => true; diff --git a/apps/control-plane/cmd/control-plane/fleet_orchestrator.go b/apps/control-plane/cmd/control-plane/fleet_orchestrator.go new file mode 100644 index 0000000..c836f41 --- /dev/null +++ b/apps/control-plane/cmd/control-plane/fleet_orchestrator.go @@ -0,0 +1,175 @@ +package main + +import ( + "sync" + "time" + + "iop/apps/control-plane/internal/wire" +) + +// fleetOptions configures the fleet orchestration service: per-call timeouts, +// bounded concurrency for status/command fan-out, and a short freshness cache +// for connected-edge status views. Zero values fall back to package defaults so +// callers and tests can override only the fields they care about. +type fleetOptions struct { + StatusTimeout time.Duration + CommandTimeout time.Duration + MaxConcurrentStatus int + MaxConcurrentCommands int + StatusCacheTTL time.Duration + Now func() time.Time +} + +const ( + defaultFleetStatusTimeout = 5 * time.Second + defaultFleetCommandTimeout = 10 * time.Second + defaultFleetMaxConcurrentStatus = 4 + defaultFleetMaxConcurrentCommands = 4 + defaultFleetStatusCacheTTL = 2 * time.Second +) + +func (o fleetOptions) withDefaults() fleetOptions { + if o.StatusTimeout <= 0 { + o.StatusTimeout = defaultFleetStatusTimeout + } + if o.CommandTimeout <= 0 { + o.CommandTimeout = defaultFleetCommandTimeout + } + if o.MaxConcurrentStatus <= 0 { + o.MaxConcurrentStatus = defaultFleetMaxConcurrentStatus + } + if o.MaxConcurrentCommands <= 0 { + o.MaxConcurrentCommands = defaultFleetMaxConcurrentCommands + } + if o.StatusCacheTTL <= 0 { + o.StatusCacheTTL = defaultFleetStatusCacheTTL + } + if o.Now == nil { + o.Now = time.Now + } + return o +} + +// fleetService orchestrates fleet-wide status and command fan-out over the Edge +// registry with bounded concurrency, per-call timeouts, and a short freshness +// cache for connected-edge status. It keeps the existing JSON contracts; it only +// changes how the views are gathered. +type fleetService struct { + registry *wire.EdgeRegistry + requestStatus edgeStatusRequester + sendCommand edgeCommandSender + opts fleetOptions + + cacheMu sync.Mutex + cache map[string]fleetStatusCacheEntry +} + +type fleetStatusCacheEntry struct { + view fleetEdgeView + expiry time.Time +} + +func newFleetService(registry *wire.EdgeRegistry, requestStatus edgeStatusRequester, sendCommand edgeCommandSender, opts fleetOptions) *fleetService { + return &fleetService{ + registry: registry, + requestStatus: requestStatus, + sendCommand: sendCommand, + opts: opts.withDefaults(), + cache: make(map[string]fleetStatusCacheEntry), + } +} + +// Status returns one view per registered Edge in registry snapshot order. +// Connected-edge status is fetched with bounded concurrency and served from a +// short freshness cache; disconnected edges reflect registry state immediately +// and are never cached. +func (s *fleetService) Status() fleetStatusResponse { + states := s.registry.Snapshots() + views := make([]fleetEdgeView, len(states)) + + sem := make(chan struct{}, s.opts.MaxConcurrentStatus) + var wg sync.WaitGroup + for i, state := range states { + // Disconnected edges (and the no-requester case) need no wire call and + // must reflect registry state immediately, so they bypass the cache and + // the concurrency-bounded worker pool. + if !state.Connected || s.requestStatus == nil { + views[i] = fleetEdgeViewFromState(state, s.requestStatus, s.opts.StatusTimeout) + continue + } + if cached, ok := s.cachedStatus(state.EdgeID); ok { + views[i] = cached + continue + } + wg.Add(1) + sem <- struct{}{} + go func(i int, state wire.EdgeConnectionState) { + defer wg.Done() + defer func() { <-sem }() + view := fleetEdgeViewFromState(state, s.requestStatus, s.opts.StatusTimeout) + s.storeStatus(state.EdgeID, view) + views[i] = view + }(i, state) + } + wg.Wait() + + return fleetStatusResponse{GeneratedAt: s.opts.Now().UTC(), Edges: views} +} + +func (s *fleetService) cachedStatus(edgeID string) (fleetEdgeView, bool) { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + entry, ok := s.cache[edgeID] + if !ok || !entry.expiry.After(s.opts.Now()) { + return fleetEdgeView{}, false + } + return entry.view, true +} + +func (s *fleetService) storeStatus(edgeID string, view fleetEdgeView) { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + s.cache[edgeID] = fleetStatusCacheEntry{ + view: view, + expiry: s.opts.Now().Add(s.opts.StatusCacheTTL), + } +} + +// Command fans out a surface-neutral command to every connected Edge with +// bounded concurrency, preserving connected snapshot order in the results. +// Disconnected edges are skipped and per-edge errors are surfaced in the result +// view. The caller is responsible for body validation and the nil-dispatcher +// case before invoking Command. +func (s *fleetService) Command(operation, targetSelector string, parameters map[string]string) fleetCommandResponse { + connected := make([]wire.EdgeConnectionState, 0) + for _, state := range s.registry.Snapshots() { + if state.Connected { + connected = append(connected, state) + } + } + + results := make([]edgeCommandResponseView, len(connected)) + sem := make(chan struct{}, s.opts.MaxConcurrentCommands) + var wg sync.WaitGroup + for i, state := range connected { + wg.Add(1) + sem <- struct{}{} + go func(i int, state wire.EdgeConnectionState) { + defer wg.Done() + defer func() { <-sem }() + resp, err := s.sendCommand(state.EdgeID, operation, targetSelector, parameters, s.opts.CommandTimeout) + if err != nil { + results[i] = edgeCommandResponseView{ + EdgeID: state.EdgeID, + Status: "error", + Error: err.Error(), + } + return + } + results[i] = edgeCommandResponseViewFromProto(resp) + }(i, state) + } + wg.Wait() + + return fleetCommandResponse{Operation: operation, Results: results} +} diff --git a/apps/control-plane/cmd/control-plane/http_fleet_handlers.go b/apps/control-plane/cmd/control-plane/http_fleet_handlers.go index a639fc8..0fe6b6b 100644 --- a/apps/control-plane/cmd/control-plane/http_fleet_handlers.go +++ b/apps/control-plane/cmd/control-plane/http_fleet_handlers.go @@ -3,23 +3,25 @@ package main import ( "encoding/json" "net/http" - "time" "iop/apps/control-plane/internal/wire" ) func registerFleetHandlers(mux *http.ServeMux, registry *wire.EdgeRegistry, requestStatus edgeStatusRequester, sendCommand edgeCommandSender) { + registerFleetHandlersWithOptions(mux, registry, requestStatus, sendCommand, fleetOptions{}) +} + +// registerFleetHandlersWithOptions wires the fleet endpoints to a fleetService +// built from the given options. It exists so tests can inject timeout, +// concurrency, and cache policy without changing the default production wiring. +func registerFleetHandlersWithOptions(mux *http.ServeMux, registry *wire.EdgeRegistry, requestStatus edgeStatusRequester, sendCommand edgeCommandSender, opts fleetOptions) { + service := newFleetService(registry, requestStatus, sendCommand, opts) mux.HandleFunc("/fleet/status", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w, http.MethodGet) return } - states := registry.Snapshots() - views := make([]fleetEdgeView, 0, len(states)) - for _, state := range states { - views = append(views, fleetEdgeViewFromState(state, requestStatus)) - } - writeJSON(w, http.StatusOK, fleetStatusResponse{GeneratedAt: time.Now().UTC(), Edges: views}) + writeJSON(w, http.StatusOK, service.Status()) }) mux.HandleFunc("/fleet/commands", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -39,22 +41,6 @@ func registerFleetHandlers(mux *http.ServeMux, registry *wire.EdgeRegistry, requ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "command dispatch is not configured"}) return } - results := make([]edgeCommandResponseView, 0) - for _, state := range registry.Snapshots() { - if !state.Connected { - continue - } - resp, err := sendCommand(state.EdgeID, body.Operation, body.TargetSelector, body.Parameters, 10*time.Second) - if err != nil { - results = append(results, edgeCommandResponseView{ - EdgeID: state.EdgeID, - Status: "error", - Error: err.Error(), - }) - continue - } - results = append(results, edgeCommandResponseViewFromProto(resp)) - } - writeJSON(w, http.StatusAccepted, fleetCommandResponse{Operation: body.Operation, Results: results}) + writeJSON(w, http.StatusAccepted, service.Command(body.Operation, body.TargetSelector, body.Parameters)) }) } diff --git a/apps/control-plane/cmd/control-plane/http_views.go b/apps/control-plane/cmd/control-plane/http_views.go index b5bb70b..19ee0e1 100644 --- a/apps/control-plane/cmd/control-plane/http_views.go +++ b/apps/control-plane/cmd/control-plane/http_views.go @@ -309,7 +309,7 @@ func edgeNodeEventViewFromRecord(record wire.EdgeNodeEventRecord) edgeNodeEventV return view } -func fleetEdgeViewFromState(state wire.EdgeConnectionState, requestStatus edgeStatusRequester) fleetEdgeView { +func fleetEdgeViewFromState(state wire.EdgeConnectionState, requestStatus edgeStatusRequester, statusTimeout time.Duration) fleetEdgeView { view := fleetEdgeView{ EdgeID: state.EdgeID, EdgeName: state.EdgeName, @@ -326,7 +326,7 @@ func fleetEdgeViewFromState(state wire.EdgeConnectionState, requestStatus edgeSt view.Health = "online" return view } - resp, err := requestStatus(state.EdgeID, 5*time.Second) + resp, err := requestStatus(state.EdgeID, statusTimeout) if err != nil { view.Health = "degraded" view.Error = err.Error() diff --git a/apps/control-plane/cmd/control-plane/main_test.go b/apps/control-plane/cmd/control-plane/main_test.go index 1110c49..d093b30 100644 --- a/apps/control-plane/cmd/control-plane/main_test.go +++ b/apps/control-plane/cmd/control-plane/main_test.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "strings" + "sync" + "sync/atomic" "testing" "time" @@ -808,9 +810,12 @@ func TestFleetCommandsHTTPHandlerFansOutToConnectedEdgesOnly(t *testing.T) { registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-off"}, at) registry.MarkDisconnected("edge-off", 3, at.Add(time.Second), proto_socket.DisconnectInfo{Reason: "closed"}) + var dispatchMu sync.Mutex var dispatched []string sendCommand := func(edgeID, operation, targetSelector string, parameters map[string]string, timeout time.Duration) (*iop.EdgeCommandResponse, error) { + dispatchMu.Lock() dispatched = append(dispatched, edgeID) + dispatchMu.Unlock() return &iop.EdgeCommandResponse{CommandId: "cmd-" + edgeID, EdgeId: edgeID, Status: "ok"}, nil } @@ -887,3 +892,178 @@ func TestHTTPMuxRegistersEdgeHandlers(t *testing.T) { } }) } + +func TestFleetStatusHTTPHandlerUsesConfiguredTimeout(t *testing.T) { + registry := wire.NewEdgeRegistry() + at := time.Unix(1780142200, 0).UTC() + registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-a"}, at) + + var gotTimeout time.Duration + requestStatus := func(edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error) { + gotTimeout = timeout + return &iop.EdgeStatusResponse{EdgeId: edgeID}, nil + } + + mux := http.NewServeMux() + registerFleetHandlersWithOptions(mux, registry, requestStatus, nil, fleetOptions{StatusTimeout: 1234 * time.Millisecond}) + + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/fleet/status", nil)) + if resp.Code != http.StatusOK { + t.Fatalf("GET /fleet/status status=%d body=%s", resp.Code, resp.Body.String()) + } + if gotTimeout != 1234*time.Millisecond { + t.Fatalf("requester timeout=%s, want %s", gotTimeout, 1234*time.Millisecond) + } +} + +func TestFleetStatusHTTPHandlerBoundsConcurrentStatusRequests(t *testing.T) { + registry := wire.NewEdgeRegistry() + at := time.Unix(1780142200, 0).UTC() + for _, id := range []string{"edge-1", "edge-2", "edge-3", "edge-4", "edge-5"} { + registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: id}, at) + } + + var inFlight, maxInFlight int32 + requestStatus := func(edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error) { + cur := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&maxInFlight) + if cur <= old || atomic.CompareAndSwapInt32(&maxInFlight, old, cur) { + break + } + } + time.Sleep(20 * time.Millisecond) + atomic.AddInt32(&inFlight, -1) + return &iop.EdgeStatusResponse{EdgeId: edgeID}, nil + } + + mux := http.NewServeMux() + registerFleetHandlersWithOptions(mux, registry, requestStatus, nil, fleetOptions{MaxConcurrentStatus: 2}) + + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/fleet/status", nil)) + if resp.Code != http.StatusOK { + t.Fatalf("GET /fleet/status status=%d body=%s", resp.Code, resp.Body.String()) + } + if got := atomic.LoadInt32(&maxInFlight); got > 2 { + t.Fatalf("max in-flight status requests=%d, want <= 2", got) + } + + var got fleetStatusResponse + if err := json.Unmarshal(resp.Body.Bytes(), &got); err != nil { + t.Fatalf("decode fleet status: %v", err) + } + wantOrder := []string{"edge-1", "edge-2", "edge-3", "edge-4", "edge-5"} + if len(got.Edges) != len(wantOrder) { + t.Fatalf("expected %d edges, got %d", len(wantOrder), len(got.Edges)) + } + for i, want := range wantOrder { + if got.Edges[i].EdgeID != want { + t.Fatalf("edge[%d]=%q, want %q (response must keep snapshot order)", i, got.Edges[i].EdgeID, want) + } + } +} + +func TestFleetStatusHTTPHandlerReusesFreshStatusCache(t *testing.T) { + registry := wire.NewEdgeRegistry() + at := time.Unix(1780142200, 0).UTC() + registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-a"}, at) + + var calls int32 + requestStatus := func(edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error) { + atomic.AddInt32(&calls, 1) + return &iop.EdgeStatusResponse{EdgeId: edgeID}, nil + } + + mux := http.NewServeMux() + registerFleetHandlersWithOptions(mux, registry, requestStatus, nil, fleetOptions{StatusCacheTTL: time.Minute}) + + for i := 0; i < 2; i++ { + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/fleet/status", nil)) + if resp.Code != http.StatusOK { + t.Fatalf("GET /fleet/status #%d status=%d body=%s", i, resp.Code, resp.Body.String()) + } + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("requester call count=%d, want 1 (fresh cache must be reused)", got) + } +} + +func TestFleetCommandsHTTPHandlerBoundsConcurrentFanout(t *testing.T) { + registry := wire.NewEdgeRegistry() + at := time.Unix(1780142200, 0).UTC() + for _, id := range []string{"edge-1", "edge-2", "edge-3", "edge-4", "edge-5"} { + registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: id}, at) + } + registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-off"}, at) + registry.MarkDisconnected("edge-off", 6, at.Add(time.Second), proto_socket.DisconnectInfo{Reason: "closed"}) + + var inFlight, maxInFlight int32 + sendCommand := func(edgeID, operation, targetSelector string, parameters map[string]string, timeout time.Duration) (*iop.EdgeCommandResponse, error) { + cur := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&maxInFlight) + if cur <= old || atomic.CompareAndSwapInt32(&maxInFlight, old, cur) { + break + } + } + time.Sleep(20 * time.Millisecond) + atomic.AddInt32(&inFlight, -1) + return &iop.EdgeCommandResponse{CommandId: "cmd-" + edgeID, EdgeId: edgeID, Status: "ok"}, nil + } + + mux := http.NewServeMux() + registerFleetHandlersWithOptions(mux, registry, nil, sendCommand, fleetOptions{MaxConcurrentCommands: 2}) + + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest(http.MethodPost, "/fleet/commands", strings.NewReader(`{"operation":"drain"}`))) + if resp.Code != http.StatusAccepted { + t.Fatalf("POST /fleet/commands status=%d body=%s", resp.Code, resp.Body.String()) + } + if got := atomic.LoadInt32(&maxInFlight); got > 2 { + t.Fatalf("max in-flight command dispatch=%d, want <= 2", got) + } + + var got fleetCommandResponse + if err := json.Unmarshal(resp.Body.Bytes(), &got); err != nil { + t.Fatalf("decode fleet command response: %v", err) + } + wantOrder := []string{"edge-1", "edge-2", "edge-3", "edge-4", "edge-5"} + if len(got.Results) != len(wantOrder) { + t.Fatalf("expected fan-out to %d connected edges, got %d: %+v", len(wantOrder), len(got.Results), got.Results) + } + for i, want := range wantOrder { + if got.Results[i].EdgeID != want { + t.Fatalf("result[%d]=%q, want %q (results must keep connected snapshot order)", i, got.Results[i].EdgeID, want) + } + if got.Results[i].EdgeID == "edge-off" { + t.Fatalf("command was dispatched to a disconnected edge: %+v", got.Results) + } + } +} + +func TestFleetCommandsHTTPHandlerUsesConfiguredTimeout(t *testing.T) { + registry := wire.NewEdgeRegistry() + at := time.Unix(1780142200, 0).UTC() + registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-a"}, at) + + var gotTimeout time.Duration + sendCommand := func(edgeID, operation, targetSelector string, parameters map[string]string, timeout time.Duration) (*iop.EdgeCommandResponse, error) { + gotTimeout = timeout + return &iop.EdgeCommandResponse{CommandId: "cmd-" + edgeID, EdgeId: edgeID, Status: "ok"}, nil + } + + mux := http.NewServeMux() + registerFleetHandlersWithOptions(mux, registry, nil, sendCommand, fleetOptions{CommandTimeout: 4321 * time.Millisecond}) + + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest(http.MethodPost, "/fleet/commands", strings.NewReader(`{"operation":"drain"}`))) + if resp.Code != http.StatusAccepted { + t.Fatalf("POST /fleet/commands status=%d body=%s", resp.Code, resp.Body.String()) + } + if gotTimeout != 4321*time.Millisecond { + t.Fatalf("sendCommand timeout=%s, want %s", gotTimeout, 4321*time.Millisecond) + } +}