feat: control plane API data binding 구현 및 oto_console surface 추가
- execution surface 및 preview smoke 테스트 문서 업데이트 - client 앱 및 oto_console UI surface (artifacts, executions, jobs) 추가 - 관련 테스트 및 계약 문서 업데이트 - archive 구조 정리
This commit is contained in:
parent
002f9cd521
commit
f76b7fa850
18 changed files with 2233 additions and 52 deletions
|
|
@ -21,7 +21,7 @@
|
|||
- [완료] Flutter 콘솔 스켈레톤
|
||||
- 경로: `agent-roadmap/archive/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md`
|
||||
- 요약: bootstrap과 runner/node 연결 기준을 바탕으로 `apps/client`와 `packages/flutter/oto_console`에 Control Plane 주요 화면, 상태 표시, 빈 상태를 갖춘 UI 스켈레톤을 만든다.
|
||||
- [계획] Control Plane API 데이터 바인딩
|
||||
- [진행중] Control Plane API 데이터 바인딩
|
||||
- 경로: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md`
|
||||
- 요약: UI 스켈레톤에 기존 OTO Core runner/job/execution/log/artifact API의 read path를 연결하고, 실제 데이터가 비어 있거나 실패할 때의 상태 표현을 검증한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ Flutter 콘솔 스켈레톤의 Overview, Runners, Pipelines/Jobs, Executions, Ar
|
|||
UI 스켈레톤을 실제 Core 응답을 소비하는 제품 표면으로 전환한다.
|
||||
|
||||
- [x] [client-ports] Flutter client에 runners/jobs/executions/logs/artifacts 조회 adapter를 추가하고 `OtoConsoleConfig.serverHttpUrl`을 source of truth로 사용한다. 검증: `cd apps/client && flutter test`에서 adapter 상태 변환 테스트가 통과한다.
|
||||
- [ ] [runner-surface] Runners 화면이 Core runner registry 응답을 loading/empty/error/data 상태로 표시한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 runners surface 테스트가 통과한다.
|
||||
- [x] [runner-surface] Runners 화면이 Core runner registry 응답을 loading/empty/error/data 상태로 표시한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 runners surface 테스트가 통과한다.
|
||||
- [ ] [execution-surface] Pipelines/Jobs와 Executions 화면이 job/execution read model을 표시하고 로그/아티팩트 진입 위치를 제공한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 jobs/executions surface 테스트가 통과한다.
|
||||
- [ ] [preview-data-smoke] 원격 preview `http://toki-labs.com:13020/` 기준으로 Core health/readiness와 최소 한 화면의 실제 API 상태가 렌더링된다. 검증: 원격 runner 또는 local smoke에서 browser/screenshot evidence와 `curl` evidence를 남긴다.
|
||||
|
||||
|
|
@ -67,6 +67,9 @@ UI 스켈레톤을 실제 Core 응답을 소비하는 제품 표면으로 전환
|
|||
- 표준선(선택): `OtoConsoleConfig.serverHttpUrl`을 API base URL로 유지하고, UI 패키지는 transport 구현보다 상태/표현 contract에 집중한다.
|
||||
- 표준선(선택): Core HTTP route는 현재 CORS/preflight를 제공하므로 web preview는 cross-origin API smoke를 포함한다.
|
||||
- 진행 메모: [client-ports] `apps/client`에 Core read adapter와 상태 변환 테스트를 추가했고 `cd apps/client && flutter test`, `cd apps/client && flutter analyze`가 통과했다. (2026-06-17)
|
||||
- 진행 메모: [runner-surface] `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log` 기준 PASS. `cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test`, 양쪽 `flutter analyze`가 통과했다. (2026-06-17)
|
||||
- 진행 메모: [execution-surface] 큰 작업으로 `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/PLAN-local-G06.md` active plan이 준비되어 있고, predecessor `01`은 archive `complete.log`로 충족됐다. (2026-06-17)
|
||||
- 진행 메모: [preview-data-smoke] 큰/원격 evidence 작업으로 `agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md` active plan이 준비되어 있으며, predecessor `02` 완료 대기 중이다. (2026-06-17)
|
||||
- 선행 작업: `Flutter 콘솔 스켈레톤`
|
||||
- 후속 작업: 쓰기 액션, 인증/권한, 운영 대시보드 고도화
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
<!-- task=m-control-plane-api-data-binding/02+01_execution_surface plan=0 tag=API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
|
||||
> **[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-17
|
||||
task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md`
|
||||
- Task ids:
|
||||
- `execution-surface`: Pipelines/Jobs와 Executions 화면이 job/execution read model을 표시하고 로그/아티팩트 진입 위치를 제공한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/02+01_execution_surface/`로 이동한다. 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-control-plane-api-data-binding`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Console jobs/executions/artifacts surfaces | [x] |
|
||||
| [API-2] Client job/execution read wiring | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `packages/flutter/oto_console`에 jobs/executions/logs/artifacts surface용 view model과 widgets를 추가하고 상태별 widget test를 작성한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 jobs/executions surface 테스트가 통과한다.
|
||||
- [x] `apps/client`가 `OtoCoreReadClient.fetchJob`, `fetchExecution`, `fetchLogs`, `fetchArtifacts` 결과를 surface snapshot으로 변환해 `OtoConsoleShell.pipelines`, `executions`, `artifacts`에 주입하고 fake read client test를 작성한다. 검증: `cd apps/client && flutter test`가 통과한다.
|
||||
- [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_G06_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_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-control-plane-api-data-binding/02+01_execution_surface/`를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/02+01_execution_surface/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-control-plane-api-data-binding`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-api-data-binding/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- `OtoConsoleShell`에 `didUpdateWidget`을 구현하여 `initialSection` 프로퍼티의 업데이트를 감지해 내부 `_section`을 동기화하도록 보강했습니다. 이를 통해 parent client app(OtoClientApp)의 탭 조작(예: Job ID 클릭 시 Execution 탭으로 이동 및 자동 펼침)이 자연스럽게 연동되도록 개선했습니다. 그 외에는 계획 대비 변경 사항이 없습니다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
1. **상태 모델 확장**: `packages/flutter/oto_console` 패키지에 `OtoJobViewModel`, `OtoExecutionViewModel`, `OtoLogEntryViewModel`, `OtoArtifactViewModel`을 정의하여 snapshot 기반의 declarative한 상태 UI 렌더링에 적합하도록 구축했습니다.
|
||||
2. **Expandable Executions Row**: `OtoExecutionsSurface`는 리스트의 각 아이템 클릭 시 펼쳐지는(expandable) 구조로 설계하고, 펼쳐진 세부 정보 영역에서 해당 execution의 logs preview 및 artifacts 리스트를 비동기로 lazy loading하여 표시하도록 설계했습니다.
|
||||
3. **양방향 네비게이션 동기화**: `OtoConsoleShell`의 `initialSection` 업데이트 감지 기능을 보완하여 parent(ClientApp)의 상태에 따른 active tab 제어를 매끄럽게 지원합니다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `01_runner_surface`의 archive `complete.log`(`agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`)가 존재하고 공통 state contract를 실제로 재사용했는지 확인한다.
|
||||
- Core route/schema 변경 없이 id 기반 job/execution/log/artifact read path만 소비했는지 확인한다.
|
||||
- logs/artifacts 진입 위치가 execution 화면에서 사용자가 찾을 수 있게 노출되는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### API-1 중간 검증
|
||||
```bash
|
||||
$ cd packages/flutter/oto_console && flutter test
|
||||
00:00 +0: ...space/oto/packages/flutter/oto_console/test/oto_console_test.dart
|
||||
00:01 +0: ...space/oto/packages/flutter/oto_console/test/oto_console_test.dart
|
||||
00:02 +0: ...space/oto/packages/flutter/oto_console/test/oto_console_test.dart
|
||||
00:02 +0: exports console contract models without shell dependency
|
||||
00:02 +1: exports console contract models without shell dependency
|
||||
00:02 +1: renders embeddable OTO console shell
|
||||
00:03 +1: renders embeddable OTO console shell
|
||||
00:03 +2: renders embeddable OTO console shell
|
||||
00:03 +2: renders section surfaces with empty states
|
||||
00:03 +3: renders section surfaces with empty states
|
||||
00:03 +3: OtoRunnersSurface renders loading state
|
||||
00:03 +4: OtoRunnersSurface renders loading state
|
||||
00:03 +4: OtoRunnersSurface renders empty state
|
||||
00:03 +5: OtoRunnersSurface renders empty state
|
||||
00:03 +5: OtoRunnersSurface renders error state
|
||||
00:03 +6: OtoRunnersSurface renders error state
|
||||
00:03 +6: OtoRunnersSurface renders data state with runners list
|
||||
00:03 +7: OtoRunnersSurface renders data state with runners list
|
||||
00:03 +7: OtoJobsSurface renders loading/empty/error/data states
|
||||
00:03 +8: OtoJobsSurface renders loading/empty/error/data states
|
||||
00:03 +8: ... renders loading/empty/error/data states and expanded detail
|
||||
00:03 +9: ... renders loading/empty/error/data states and expanded detail
|
||||
00:03 +9: OtoArtifactsSurface renders loading/empty/error/data states
|
||||
00:04 +9: OtoArtifactsSurface renders loading/empty/error/data states
|
||||
00:04 +10: OtoArtifactsSurface renders loading/empty/error/data states
|
||||
00:04 +10: All tests passed!
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
```bash
|
||||
$ cd apps/client && flutter test
|
||||
00:00 +0: loading /config/workspace/oto/apps/client/test/widget_test.dart
|
||||
00:01 +0: loading /config/workspace/oto/apps/client/test/widget_test.dart
|
||||
00:02 +0: loading /config/workspace/oto/apps/client/test/widget_test.dart
|
||||
00:02 +0: OTO client hosts embeddable console widgets
|
||||
00:03 +0: OTO client hosts embeddable console widgets
|
||||
00:03 +1: OTO client hosts embeddable console widgets
|
||||
00:03 +1: OtoHttpCoreReadClient maps Core read responses to data states
|
||||
00:03 +2: OtoHttpCoreReadClient maps Core read responses to data states
|
||||
00:03 +2: OtoHttpCoreReadClient maps empty and error states
|
||||
00:03 +3: OtoHttpCoreReadClient maps empty and error states
|
||||
00:03 +3: OtoClientApp wires runners loading empty state when no runnerIDs
|
||||
00:03 +4: OtoClientApp wires runners loading empty state when no runnerIDs
|
||||
00:03 +4: OtoClientApp wires runners error state on API failure
|
||||
00:03 +5: OtoClientApp wires runners error state on API failure
|
||||
00:03 +5: OtoClientApp wires runners data state on API success
|
||||
00:03 +6: OtoClientApp wires runners data state on API success
|
||||
00:03 +6: OtoClientApp ... executions and artifacts data state on API success
|
||||
00:04 +6: OtoClientApp ... executions and artifacts data state on API success
|
||||
00:04 +7: OtoClientApp ... executions and artifacts data state on API success
|
||||
00:04 +7: All tests passed!
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ cd packages/flutter/oto_console && flutter test
|
||||
All tests passed!
|
||||
$ cd apps/client && flutter test
|
||||
All tests passed!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Fail
|
||||
- verification trust: Pass
|
||||
- 발견된 문제:
|
||||
- Required: `apps/client/test/widget_test.dart:381`의 client wiring 테스트가 jobs/executions/artifacts data path만 검증한다. 계획의 API-2는 fake read client로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 검증하라고 명시했지만, 현재 테스트는 job fetch error, execution fetch error, artifacts aggregate error/empty, expanded logs/artifacts empty/error 렌더링을 검증하지 않는다. 같은 파일의 `_registerJobsExecutionsTests()`에 missing empty/error widget tests를 추가하고, 각 test가 실제 `OtoClientApp` 화면 문구를 assert하도록 보강한다.
|
||||
- 다음 단계: FAIL follow-up으로 `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성한다.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<!-- task=m-control-plane-api-data-binding/02+01_execution_surface plan=0 tag=API -->
|
||||
<!-- task=m-control-plane-api-data-binding/02+01_execution_surface plan=1 tag=REVIEW_API -->
|
||||
|
||||
# Code Review Reference - API
|
||||
# Code Review Reference - REVIEW_API
|
||||
|
||||
> **[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.
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
## 개요
|
||||
|
||||
date=2026-06-17
|
||||
task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API
|
||||
task=m-control-plane-api-data-binding/02+01_execution_surface, plan=1, tag=REVIEW_API
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
|
|
@ -23,6 +23,23 @@ task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API
|
|||
- `execution-surface`: Pipelines/Jobs와 Executions 화면이 job/execution read model을 표시하고 로그/아티팩트 진입 위치를 제공한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## Archive Evidence Snapshot
|
||||
|
||||
- Archived plan: `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G06_0.log`
|
||||
- Archived review: `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G06_0.log`
|
||||
- Verdict: FAIL
|
||||
- Required summary:
|
||||
- `apps/client/test/widget_test.dart:381`의 client wiring 테스트가 jobs/executions/artifacts data path만 검증한다. API-2 계획은 fake read client로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 검증하라고 요구했다.
|
||||
- Affected files:
|
||||
- `apps/client/test/widget_test.dart`
|
||||
- 필요 시 `apps/client/lib/src/app/oto_client_app.dart`
|
||||
- Verification evidence from archived loop:
|
||||
- `cd packages/flutter/oto_console && flutter test`: 통과
|
||||
- `cd apps/client && flutter test`: 통과
|
||||
- `git diff --check`: EOF 공백 수정 후 통과
|
||||
- Allowed archive reread:
|
||||
- 위 archived plan/review log 두 파일만 좁게 읽는다.
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
|
@ -31,7 +48,7 @@ task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API
|
|||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
|
||||
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/02+01_execution_surface/`로 이동한다. 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-control-plane-api-data-binding`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
|
@ -42,40 +59,40 @@ task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API
|
|||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [API-1] Console jobs/executions/artifacts surfaces | [ ] |
|
||||
| [API-2] Client job/execution read wiring | [ ] |
|
||||
| [REVIEW_API-1] Client empty/error wiring tests | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `packages/flutter/oto_console`에 jobs/executions/logs/artifacts surface용 view model과 widgets를 추가하고 상태별 widget test를 작성한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 jobs/executions surface 테스트가 통과한다.
|
||||
- [ ] `apps/client`가 `OtoCoreReadClient.fetchJob`, `fetchExecution`, `fetchLogs`, `fetchArtifacts` 결과를 surface snapshot으로 변환해 `OtoConsoleShell.pipelines`, `executions`, `artifacts`에 주입하고 fake read client test를 작성한다. 검증: `cd apps/client && flutter test`가 통과한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
- [x] `apps/client/test/widget_test.dart`에 `OtoClientApp` fake read client 기반 jobs/executions/artifacts empty/error wiring tests를 추가한다. 검증: `cd apps/client && flutter test`에서 새 empty/error tests가 함께 통과한다.
|
||||
- [x] `git diff --check`를 실행해 task artifact와 Dart test 변경에 whitespace error가 없는지 확인한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_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-control-plane-api-data-binding/02+01_execution_surface/`를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/02+01_execution_surface/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-control-plane-api-data-binding`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-api-data-binding/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_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-control-plane-api-data-binding/02+01_execution_surface/`를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/02+01_execution_surface/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-control-plane-api-data-binding`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-api-data-binding/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
- 계획 대비 변경 사항 없음. 테스트가 실제 product behavior gap을 드러내지 않았으므로 `OtoClientApp` 제품 코드는 수정하지 않고, `widget_test.dart` 내의 integration test suite만 정교하게 보강했습니다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
1. **종합적 Widget integration Test 작성**: `_registerJobsExecutionsTests()` 내에 `testWidgets`들을 추가 정의하여 UI 레이어와 Mock API 간의 wiring을 온전히 검증하도록 설계했습니다.
|
||||
2. **Detail Expanded State 에러/비통제 케이스 검증**: Expanded detail panel의 토글(Collapse/Expand)을 모방하고, 런타임에 mock client의 내부 flag(`shouldError`)를 동적으로 스위칭하여 empty/error UI 상태 변화와 해당 시점의 assert가 정상 동작하도록 구현했습니다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
|
|
@ -91,9 +108,9 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
|
|||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `01_runner_surface`의 `complete.log`가 존재하고 공통 state contract를 실제로 재사용했는지 확인한다.
|
||||
- Core route/schema 변경 없이 id 기반 job/execution/log/artifact read path만 소비했는지 확인한다.
|
||||
- logs/artifacts 진입 위치가 execution 화면에서 사용자가 찾을 수 있게 노출되는지 확인한다.
|
||||
- `apps/client/test/widget_test.dart`가 job, execution, logs, artifacts의 empty/error 화면 상태를 fake read client로 검증하는지 확인한다.
|
||||
- 새 tests가 단순 adapter unit test가 아니라 실제 `OtoClientApp` widget에서 tab/detail 문구를 assert하는지 확인한다.
|
||||
- 제품 코드 변경이 있다면 테스트가 드러낸 최소 결함만 수정했는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
|
|
@ -106,24 +123,27 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
|
|||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### API-1 중간 검증
|
||||
```bash
|
||||
$ cd packages/flutter/oto_console && flutter test
|
||||
(output)
|
||||
```
|
||||
|
||||
### API-2 중간 검증
|
||||
### REVIEW_API-1 중간 검증
|
||||
```bash
|
||||
$ cd apps/client && flutter test
|
||||
(output)
|
||||
00:00 +0: loading /config/workspace/oto/apps/client/test/widget_test.dart
|
||||
00:00 +0: OTO client hosts embeddable console widgets
|
||||
00:01 +1: OtoHttpCoreReadClient maps Core read responses to data states
|
||||
00:01 +2: OtoHttpCoreReadClient maps empty and error states
|
||||
00:01 +3: OtoClientApp wires runners loading empty state when no runnerIDs
|
||||
00:01 +4: OtoClientApp wires runners error state on API failure
|
||||
00:01 +5: OtoClientApp wires runners data state on API success
|
||||
00:01 +6: OtoClientApp wires jobs, executions and artifacts data state on API success
|
||||
00:02 +7: OtoClientApp wires jobs, executions and artifacts empty states when no IDs
|
||||
00:02 +8: OtoClientApp wires jobs, executions and artifacts error states on API failure
|
||||
00:02 +9: OtoClientApp wires expanded execution detail logs and artifacts empty/error states
|
||||
00:03 +10: All tests passed!
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ cd packages/flutter/oto_console && flutter test
|
||||
(output)
|
||||
$ cd apps/client && flutter test
|
||||
(output)
|
||||
$ git diff --check
|
||||
(no output; exit code 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -131,3 +151,17 @@ $ cd apps/client && flutter test
|
|||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: 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로 이동한다.
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# Complete - m-control-plane-api-data-binding/02+01_execution_surface
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-17
|
||||
|
||||
## 요약
|
||||
|
||||
Control Plane API 데이터 바인딩 execution surface 작업을 2회 리뷰 루프로 완료했다. G06은 client wiring empty/error 테스트 커버리지 부족으로 FAIL, G07에서 테스트를 보강해 최종 PASS 처리했다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | 제품 코드와 기본 테스트는 동작했지만 `apps/client`의 job/execution/log/artifact empty/error widget wiring 테스트가 부족했다. |
|
||||
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | `apps/client/test/widget_test.dart`에 jobs/executions/artifacts empty/error 및 expanded detail logs/artifacts empty/error 테스트를 추가했고 검증이 통과했다. |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `packages/flutter/oto_console`에 jobs, executions, artifacts surface view model과 widgets를 추가하고 loading/empty/error/data 상태 테스트를 작성했다.
|
||||
- `apps/client`가 job/execution/log/artifact read results를 console surface snapshot으로 변환해 `OtoConsoleShell`에 주입하도록 연결했다.
|
||||
- `apps/client/test/widget_test.dart`에 fake read client 기반 data, empty, error, expanded detail 테스트를 보강했다.
|
||||
- review 중 `packages/flutter/oto_console/lib/oto_console.dart`의 EOF whitespace nit를 정리했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `cd packages/flutter/oto_console && flutter test` - PASS; 10 tests passed.
|
||||
- `cd apps/client && flutter test` - PASS; 10 tests passed.
|
||||
- `git diff --check` - PASS; no output.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md`
|
||||
- Completed task ids:
|
||||
- `execution-surface`: PASS; evidence=`plan_local_G06_0.log`, `code_review_local_G06_0.log`, `plan_local_G07_1.log`, `code_review_local_G07_1.log`; verification=`cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test`, `git diff --check`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
- split decision policy를 먼저 평가했다.
|
||||
- shared task group: `agent-task/m-control-plane-api-data-binding/`.
|
||||
- current subtask: `02+01_execution_surface`.
|
||||
- predecessor `01`: missing active/archive `complete.log`. 구현은 `agent-task/m-control-plane-api-data-binding/01_runner_surface/complete.log` 또는 대응 archive `complete.log`가 생긴 뒤 시작한다.
|
||||
- predecessor `01`: satisfied via `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`. 구현을 시작할 수 있다.
|
||||
- `03+01,02_preview_smoke`는 이 subtask와 `01_runner_surface` 완료 뒤 실행한다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
|
@ -86,9 +86,9 @@
|
|||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
1. `01_runner_surface`가 PASS되어 `complete.log`를 남긴 뒤 시작한다.
|
||||
2. 이 subtask는 `02+01_execution_surface` 디렉터리명 그대로 predecessor `01`만 요구한다.
|
||||
3. 선행 `complete.log`가 없으면 구현하지 말고 review stub의 `사용자 리뷰 요청`이 아니라 계획 대기 상태로 보고한다.
|
||||
1. `01_runner_surface`는 PASS되어 `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`를 남겼다.
|
||||
2. 이 subtask는 `02+01_execution_surface` 디렉터리명 그대로 predecessor `01`만 요구하며, 현재 선행 조건은 충족됐다.
|
||||
3. 구현 중 선행 산출물 재확인이 필요하면 위 archive `complete.log`만 좁게 확인한다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<!-- task=m-control-plane-api-data-binding/02+01_execution_surface plan=1 tag=REVIEW_API -->
|
||||
|
||||
# Plan - REVIEW_API Client Wiring Test Coverage
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 출력과 설계 결정을 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 종결 처리, log rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자만 결정할 항목, 사용자 소유 외부 환경, 범위 충돌이 발견되면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 남기고 멈춘다. 구현 중 채팅으로 직접 질문하거나 `request_user_input`을 호출하지 않는다.
|
||||
|
||||
## 배경
|
||||
|
||||
이 후속 루프는 `code_review_local_G06_0.log`의 Required 이슈만 다룬다. 제품 코드의 기본 jobs/executions/artifacts wiring과 console surface tests는 통과했지만, 원 계획의 API-2는 fake read client 기반으로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 검증하라고 요구했다. 현재 `apps/client/test/widget_test.dart`는 새 client wiring의 data path만 검증하므로 empty/error 화면 검증을 보강해야 한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 차단 사항은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 직접 사용자 프롬프트는 금지되며, code-review가 요청 타당성을 검토하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md`
|
||||
- Task ids:
|
||||
- `execution-surface`: Pipelines/Jobs와 Executions 화면이 job/execution read model을 표시하고 로그/아티팩트 진입 위치를 제공한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## Archive Evidence Snapshot
|
||||
|
||||
- Archived plan: `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G06_0.log`
|
||||
- Archived review: `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G06_0.log`
|
||||
- Verdict: FAIL
|
||||
- Required summary:
|
||||
- `apps/client/test/widget_test.dart:381`의 client wiring 테스트가 jobs/executions/artifacts data path만 검증한다. API-2 계획은 fake read client로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 검증하라고 요구했다.
|
||||
- Affected files:
|
||||
- `apps/client/test/widget_test.dart`
|
||||
- 필요 시 `apps/client/lib/src/app/oto_client_app.dart`
|
||||
- Verification evidence from archived loop:
|
||||
- `cd packages/flutter/oto_console && flutter test`: 통과
|
||||
- `cd apps/client && flutter test`: 통과
|
||||
- `git diff --check`: EOF 공백 수정 후 통과
|
||||
- Allowed archive reread:
|
||||
- 위 archived plan/review log 두 파일만 좁게 읽는다.
|
||||
|
||||
## 범위 결정 근거
|
||||
|
||||
- 이번 후속은 missing test coverage 보강이다. 제품 코드는 테스트가 실제 결함을 드러낼 때만 최소 수정한다.
|
||||
- Core route/schema, roadmap 문서, 다른 split task(`03+01,02_preview_smoke`)는 수정하지 않는다.
|
||||
- follow-up route는 `local-G07`이다. 이슈가 명시적이고 deterministic widget tests로 검증 가능하므로 cloud escalation은 필요하지 않다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `apps/client/test/widget_test.dart`에 `OtoClientApp` fake read client 기반 jobs/executions/artifacts empty/error wiring tests를 추가한다. 검증: `cd apps/client && flutter test`에서 새 empty/error tests가 함께 통과한다.
|
||||
- [ ] `git diff --check`를 실행해 task artifact와 Dart test 변경에 whitespace error가 없는지 확인한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_API-1] Client empty/error wiring tests
|
||||
|
||||
문제: `apps/client/test/widget_test.dart`의 `OtoClientApp wires jobs, executions and artifacts data state on API success`는 data path만 검증한다. 원 계획은 fake read client로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 확인하라고 요구했으므로, 현재 테스트는 plan completion evidence로 부족하다.
|
||||
|
||||
해결 방법: `_registerJobsExecutionsTests()`에 아래 상태를 실제 `OtoClientApp` widget으로 검증하는 테스트를 추가한다.
|
||||
|
||||
- no `jobIDs`/`executionIDs`일 때 Pipelines, Executions, Artifacts tab의 empty 문구가 표시된다.
|
||||
- `fetchJob` error가 Pipelines tab의 `Error loading jobs`와 error message로 표시된다.
|
||||
- `fetchExecution` error가 Executions tab의 `Error loading executions`와 error message로 표시된다.
|
||||
- `fetchArtifacts` error가 Artifacts tab의 `Error loading artifacts`와 error message로 표시된다.
|
||||
- expanded execution detail에서 `fetchLogs` empty/error와 `fetchArtifacts` empty/error가 각각 `Log is empty.`, `Error loading logs: ...`, `No artifacts produced.`, `Error loading artifacts: ...`로 표시된다.
|
||||
|
||||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] `apps/client/test/widget_test.dart`에 중복을 줄이는 test helper를 추가하거나 기존 `_FakeCoreReadClient`를 재사용한다.
|
||||
- [ ] 새 tests가 `OtoConsoleConfig.serverHttpUrl` 기반 fake client 호출을 유지하는지 확인한다.
|
||||
- [ ] 테스트가 product behavior gap을 드러내면 `apps/client/lib/src/app/oto_client_app.dart`만 최소 수정하고, 수정 사유를 review stub에 기록한다.
|
||||
|
||||
테스트 작성: 작성. 각 상태별로 화면 문구를 assert한다.
|
||||
|
||||
중간 검증:
|
||||
|
||||
```bash
|
||||
cd apps/client && flutter test
|
||||
```
|
||||
|
||||
기대 결과: 새 empty/error wiring tests 포함 전체 통과.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `apps/client/test/widget_test.dart` | REVIEW_API-1 |
|
||||
| `apps/client/lib/src/app/oto_client_app.dart` | REVIEW_API-1, 테스트가 실제 product gap을 드러낼 때만 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
cd apps/client && flutter test
|
||||
git diff --check
|
||||
```
|
||||
|
||||
기대 결과: 두 명령 모두 통과한다.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -47,7 +47,7 @@ task=m-control-plane-api-data-binding/03+01,02_preview_smoke, plan=0, tag=TEST
|
|||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] 선행 `01_runner_surface`와 `02+01_execution_surface`의 `complete.log`를 확인하고 없으면 실행하지 않는다.
|
||||
- [ ] 선행 `01_runner_surface` archive `complete.log`와 `02+01_execution_surface`의 `complete.log`를 확인하고, `02+01_execution_surface`가 없으면 실행하지 않는다.
|
||||
- [ ] 원격 Core health/readiness와 preview HTTP 응답을 curl로 수집한다. 검증: `curl` 출력이 review stub에 남는다.
|
||||
- [ ] browser screenshot과 최소 한 화면의 API-backed 상태 렌더링 evidence를 `/tmp/oto-api-binding-smoke`에 남긴다. 검증: screenshot path와 browser stdout이 review stub에 남는다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
|
@ -92,7 +92,7 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
|
|||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `01_runner_surface`와 `02+01_execution_surface` 선행 `complete.log`가 확인되었는지 검토한다.
|
||||
- `01_runner_surface` archive `complete.log`(`agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`)와 `02+01_execution_surface` 선행 `complete.log`가 확인되었는지 검토한다.
|
||||
- curl 출력이 `healthz=OK`, `readyz=OK`, preview HTTP 200 계열을 실제로 보여주는지 확인한다.
|
||||
- screenshot path가 존재하고, browser stdout 또는 screenshot evidence가 API-backed 화면 상태를 판단하기에 충분한지 확인한다.
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
|
||||
- split decision policy를 먼저 평가했다.
|
||||
- current subtask: `03+01,02_preview_smoke`.
|
||||
- predecessor `01`: missing active/archive `complete.log`.
|
||||
- predecessor `01`: satisfied via `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`.
|
||||
- predecessor `02`: missing active/archive `complete.log`.
|
||||
- 이 smoke는 `01_runner_surface`와 `02+01_execution_surface` PASS 이후만 실행한다.
|
||||
|
||||
|
|
@ -79,15 +79,15 @@
|
|||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
1. `01_runner_surface`와 `02+01_execution_surface`가 PASS되어 각각 `complete.log`를 남겼는지 확인한다.
|
||||
2. 선행 `complete.log`가 없으면 smoke를 실행하지 않고 active files를 그대로 둔다.
|
||||
1. `01_runner_surface`는 PASS되어 `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`를 남겼다.
|
||||
2. `02+01_execution_surface`가 PASS되어 `complete.log`를 남겼는지 확인한다. 없으면 smoke를 실행하지 않고 active files를 그대로 둔다.
|
||||
3. 선행 충족 후 원격 Core curl evidence를 수집한다.
|
||||
4. browser screenshot과 body text evidence를 수집한다.
|
||||
5. 모든 실제 stdout/stderr와 screenshot path를 review stub에 붙인다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] 선행 `01_runner_surface`와 `02+01_execution_surface`의 `complete.log`를 확인하고 없으면 실행하지 않는다.
|
||||
- [ ] 선행 `01_runner_surface` archive `complete.log`와 `02+01_execution_surface`의 `complete.log`를 확인하고, `02+01_execution_surface`가 없으면 실행하지 않는다.
|
||||
- [ ] 원격 Core health/readiness와 preview HTTP 응답을 curl로 수집한다. 검증: `curl` 출력이 review stub에 남는다.
|
||||
- [ ] browser screenshot과 최소 한 화면의 API-backed 상태 렌더링 evidence를 `/tmp/oto-api-binding-smoke`에 남긴다. 검증: screenshot path와 browser stdout이 review stub에 남는다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
수정 파일 및 체크리스트:
|
||||
|
||||
- [ ] source file 변경 없음.
|
||||
- [ ] `agent-task/m-control-plane-api-data-binding/01_runner_surface/complete.log` 또는 대응 archive path 확인.
|
||||
- [ ] `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log` 확인.
|
||||
- [ ] `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/complete.log` 또는 대응 archive path 확인.
|
||||
- [ ] `/tmp/oto-api-binding-smoke`에 curl output 저장.
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ class OtoClientApp extends StatefulWidget {
|
|||
final OtoCoreConnectionClient? coreClient;
|
||||
final OtoCoreReadClient? readClient;
|
||||
final List<String> runnerIDs;
|
||||
final List<String> jobIDs;
|
||||
final List<String> executionIDs;
|
||||
final OtoCapabilityPack capabilities;
|
||||
|
||||
const OtoClientApp({
|
||||
|
|
@ -52,6 +54,8 @@ class OtoClientApp extends StatefulWidget {
|
|||
this.coreClient,
|
||||
this.readClient,
|
||||
this.runnerIDs = const [],
|
||||
this.jobIDs = const [],
|
||||
this.executionIDs = const [],
|
||||
this.capabilities = otoDefaultCapabilityPack,
|
||||
});
|
||||
|
||||
|
|
@ -66,6 +70,18 @@ class _OtoClientAppState extends State<OtoClientApp> {
|
|||
late OtoCoreConnectionSnapshot _connection = widget.initialConnection;
|
||||
OtoSurfaceSnapshot<List<OtoRunnerViewModel>> _runnersSnapshot =
|
||||
const OtoSurfaceSnapshot.loading();
|
||||
OtoSurfaceSnapshot<List<OtoJobViewModel>> _jobsSnapshot =
|
||||
const OtoSurfaceSnapshot.loading();
|
||||
OtoSurfaceSnapshot<List<OtoExecutionViewModel>> _executionsSnapshot =
|
||||
const OtoSurfaceSnapshot.loading();
|
||||
OtoSurfaceSnapshot<List<OtoArtifactViewModel>> _artifactsSnapshot =
|
||||
const OtoSurfaceSnapshot.loading();
|
||||
|
||||
String? _expandedExecutionID;
|
||||
OtoSurfaceSnapshot<List<OtoLogEntryViewModel>>? _logsSnapshot;
|
||||
OtoSurfaceSnapshot<List<OtoArtifactViewModel>>? _expandedArtifactsSnapshot;
|
||||
|
||||
OtoConsoleSection _activeSection = OtoConsoleSection.overview;
|
||||
int _refreshGeneration = 0;
|
||||
|
||||
@override
|
||||
|
|
@ -80,7 +96,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
|
|||
if (oldWidget.config.serverHttpUrl != widget.config.serverHttpUrl ||
|
||||
oldWidget.coreClient != widget.coreClient ||
|
||||
oldWidget.readClient != widget.readClient ||
|
||||
!_listEquals(oldWidget.runnerIDs, widget.runnerIDs)) {
|
||||
!_listEquals(oldWidget.runnerIDs, widget.runnerIDs) ||
|
||||
!_listEquals(oldWidget.jobIDs, widget.jobIDs) ||
|
||||
!_listEquals(oldWidget.executionIDs, widget.executionIDs)) {
|
||||
_refreshCoreStatus();
|
||||
}
|
||||
}
|
||||
|
|
@ -100,6 +118,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
|
|||
setState(() {
|
||||
_connection = const OtoCoreConnectionSnapshot.checking();
|
||||
_runnersSnapshot = const OtoSurfaceSnapshot.loading();
|
||||
_jobsSnapshot = const OtoSurfaceSnapshot.loading();
|
||||
_executionsSnapshot = const OtoSurfaceSnapshot.loading();
|
||||
_artifactsSnapshot = const OtoSurfaceSnapshot.loading();
|
||||
});
|
||||
|
||||
final coreClient = widget.coreClient ?? _defaultCoreClient;
|
||||
|
|
@ -112,10 +133,31 @@ class _OtoClientAppState extends State<OtoClientApp> {
|
|||
final Future<OtoSurfaceSnapshot<List<OtoRunnerViewModel>>> runnersFuture =
|
||||
_fetchRunners(readClient);
|
||||
|
||||
final results = await Future.wait([connectionFuture, runnersFuture]);
|
||||
final Future<OtoSurfaceSnapshot<List<OtoJobViewModel>>> jobsFuture =
|
||||
_fetchJobs(readClient);
|
||||
|
||||
final Future<OtoSurfaceSnapshot<List<OtoExecutionViewModel>>> executionsFuture =
|
||||
_fetchExecutions(readClient);
|
||||
|
||||
final Future<OtoSurfaceSnapshot<List<OtoArtifactViewModel>>> artifactsFuture =
|
||||
_fetchArtifacts(readClient);
|
||||
|
||||
final results = await Future.wait([
|
||||
connectionFuture,
|
||||
runnersFuture,
|
||||
jobsFuture,
|
||||
executionsFuture,
|
||||
artifactsFuture,
|
||||
]);
|
||||
final connection = results[0] as OtoCoreConnectionSnapshot;
|
||||
final runnersSnapshot =
|
||||
results[1] as OtoSurfaceSnapshot<List<OtoRunnerViewModel>>;
|
||||
final jobsSnapshot =
|
||||
results[2] as OtoSurfaceSnapshot<List<OtoJobViewModel>>;
|
||||
final executionsSnapshot =
|
||||
results[3] as OtoSurfaceSnapshot<List<OtoExecutionViewModel>>;
|
||||
final artifactsSnapshot =
|
||||
results[4] as OtoSurfaceSnapshot<List<OtoArtifactViewModel>>;
|
||||
|
||||
if (!mounted || generation != _refreshGeneration) {
|
||||
return;
|
||||
|
|
@ -124,7 +166,14 @@ class _OtoClientAppState extends State<OtoClientApp> {
|
|||
setState(() {
|
||||
_connection = connection;
|
||||
_runnersSnapshot = runnersSnapshot;
|
||||
_jobsSnapshot = jobsSnapshot;
|
||||
_executionsSnapshot = executionsSnapshot;
|
||||
_artifactsSnapshot = artifactsSnapshot;
|
||||
});
|
||||
|
||||
if (_expandedExecutionID != null) {
|
||||
_loadExecutionDetail(_expandedExecutionID);
|
||||
}
|
||||
}
|
||||
|
||||
Future<OtoSurfaceSnapshot<List<OtoRunnerViewModel>>> _fetchRunners(
|
||||
|
|
@ -198,6 +247,253 @@ class _OtoClientAppState extends State<OtoClientApp> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<OtoSurfaceSnapshot<List<OtoJobViewModel>>> _fetchJobs(
|
||||
OtoCoreReadClient readClient,
|
||||
) async {
|
||||
if (widget.jobIDs.isEmpty) {
|
||||
return const OtoSurfaceSnapshot.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
final results = await Future.wait(
|
||||
widget.jobIDs.map((id) => readClient.fetchJob(widget.config, id)),
|
||||
);
|
||||
|
||||
final viewModels = <OtoJobViewModel>[];
|
||||
String? errorMessage;
|
||||
bool isLoading = false;
|
||||
bool isEmpty = true;
|
||||
|
||||
for (final result in results) {
|
||||
if (result.state == OtoCoreReadState.error) {
|
||||
errorMessage = result.message;
|
||||
break;
|
||||
}
|
||||
if (result.state == OtoCoreReadState.loading) {
|
||||
isLoading = true;
|
||||
break;
|
||||
}
|
||||
if (result.state == OtoCoreReadState.data) {
|
||||
isEmpty = false;
|
||||
final record = result.data!;
|
||||
viewModels.add(
|
||||
OtoJobViewModel(
|
||||
jobID: record.id,
|
||||
name: record.name,
|
||||
state: record.state,
|
||||
executionID: record.executionID,
|
||||
createdAt: record.createdAt?.toIso8601String() ?? '',
|
||||
updatedAt: record.updatedAt?.toIso8601String() ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessage != null) {
|
||||
return OtoSurfaceSnapshot.error(errorMessage);
|
||||
} else if (isLoading) {
|
||||
return const OtoSurfaceSnapshot.loading();
|
||||
} else if (isEmpty) {
|
||||
return const OtoSurfaceSnapshot.empty();
|
||||
} else {
|
||||
return OtoSurfaceSnapshot.data(viewModels);
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
return OtoSurfaceSnapshot.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<OtoSurfaceSnapshot<List<OtoExecutionViewModel>>> _fetchExecutions(
|
||||
OtoCoreReadClient readClient,
|
||||
) async {
|
||||
if (widget.executionIDs.isEmpty) {
|
||||
return const OtoSurfaceSnapshot.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
final results = await Future.wait(
|
||||
widget.executionIDs.map((id) => readClient.fetchExecution(widget.config, id)),
|
||||
);
|
||||
|
||||
final viewModels = <OtoExecutionViewModel>[];
|
||||
String? errorMessage;
|
||||
bool isLoading = false;
|
||||
bool isEmpty = true;
|
||||
|
||||
for (final result in results) {
|
||||
if (result.state == OtoCoreReadState.error) {
|
||||
errorMessage = result.message;
|
||||
break;
|
||||
}
|
||||
if (result.state == OtoCoreReadState.loading) {
|
||||
isLoading = true;
|
||||
break;
|
||||
}
|
||||
if (result.state == OtoCoreReadState.data) {
|
||||
isEmpty = false;
|
||||
final record = result.data!;
|
||||
viewModels.add(
|
||||
OtoExecutionViewModel(
|
||||
executionID: record.id,
|
||||
jobID: record.jobID,
|
||||
state: record.state,
|
||||
runnerID: '',
|
||||
createdAt: record.createdAt?.toIso8601String() ?? '',
|
||||
updatedAt: record.updatedAt?.toIso8601String() ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessage != null) {
|
||||
return OtoSurfaceSnapshot.error(errorMessage);
|
||||
} else if (isLoading) {
|
||||
return const OtoSurfaceSnapshot.loading();
|
||||
} else if (isEmpty) {
|
||||
return const OtoSurfaceSnapshot.empty();
|
||||
} else {
|
||||
return OtoSurfaceSnapshot.data(viewModels);
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
return OtoSurfaceSnapshot.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<OtoSurfaceSnapshot<List<OtoArtifactViewModel>>> _fetchArtifacts(
|
||||
OtoCoreReadClient readClient,
|
||||
) async {
|
||||
if (widget.executionIDs.isEmpty) {
|
||||
return const OtoSurfaceSnapshot.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
final results = await Future.wait(
|
||||
widget.executionIDs.map((id) => readClient.fetchArtifacts(widget.config, id)),
|
||||
);
|
||||
|
||||
final viewModels = <OtoArtifactViewModel>[];
|
||||
String? errorMessage;
|
||||
bool isLoading = false;
|
||||
bool isEmpty = true;
|
||||
|
||||
for (final result in results) {
|
||||
if (result.state == OtoCoreReadState.error) {
|
||||
errorMessage = result.message;
|
||||
break;
|
||||
}
|
||||
if (result.state == OtoCoreReadState.loading) {
|
||||
isLoading = true;
|
||||
break;
|
||||
}
|
||||
if (result.state == OtoCoreReadState.data || result.state == OtoCoreReadState.empty) {
|
||||
if (result.state == OtoCoreReadState.data) {
|
||||
isEmpty = false;
|
||||
final records = result.data ?? [];
|
||||
for (final record in records) {
|
||||
viewModels.add(
|
||||
OtoArtifactViewModel(
|
||||
name: record.name,
|
||||
path: record.path,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessage != null) {
|
||||
return OtoSurfaceSnapshot.error(errorMessage);
|
||||
} else if (isLoading) {
|
||||
return const OtoSurfaceSnapshot.loading();
|
||||
} else if (isEmpty && viewModels.isEmpty) {
|
||||
return const OtoSurfaceSnapshot.empty();
|
||||
} else {
|
||||
return OtoSurfaceSnapshot.data(viewModels);
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
return OtoSurfaceSnapshot.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadExecutionDetail(String? execID) async {
|
||||
if (execID == null) {
|
||||
setState(() {
|
||||
_expandedExecutionID = null;
|
||||
_logsSnapshot = null;
|
||||
_expandedArtifactsSnapshot = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_expandedExecutionID = execID;
|
||||
_logsSnapshot = const OtoSurfaceSnapshot.loading();
|
||||
_expandedArtifactsSnapshot = const OtoSurfaceSnapshot.loading();
|
||||
});
|
||||
|
||||
final readClient = widget.readClient ?? _defaultReadClient;
|
||||
|
||||
try {
|
||||
final Future<OtoCoreReadResult<List<OtoExecutionLogEntry>>> logsFuture =
|
||||
readClient.fetchLogs(widget.config, execID);
|
||||
|
||||
final Future<OtoCoreReadResult<List<OtoArtifactRecord>>> artifactsFuture =
|
||||
readClient.fetchArtifacts(widget.config, execID);
|
||||
|
||||
final results = await Future.wait([logsFuture, artifactsFuture]);
|
||||
final logsResult = results[0] as OtoCoreReadResult<List<OtoExecutionLogEntry>>;
|
||||
final artifactsResult = results[1] as OtoCoreReadResult<List<OtoArtifactRecord>>;
|
||||
|
||||
if (!mounted || _expandedExecutionID != execID) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (logsResult.state == OtoCoreReadState.error) {
|
||||
_logsSnapshot = OtoSurfaceSnapshot.error(logsResult.message);
|
||||
} else if (logsResult.state == OtoCoreReadState.loading) {
|
||||
_logsSnapshot = const OtoSurfaceSnapshot.loading();
|
||||
} else if (logsResult.state == OtoCoreReadState.empty || logsResult.data == null) {
|
||||
_logsSnapshot = const OtoSurfaceSnapshot.empty();
|
||||
} else {
|
||||
_logsSnapshot = OtoSurfaceSnapshot.data(
|
||||
logsResult.data!
|
||||
.map((e) => OtoLogEntryViewModel(
|
||||
timestamp: e.timestamp?.toIso8601String() ?? '',
|
||||
line: e.line,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
if (artifactsResult.state == OtoCoreReadState.error) {
|
||||
_expandedArtifactsSnapshot =
|
||||
OtoSurfaceSnapshot.error(artifactsResult.message);
|
||||
} else if (artifactsResult.state == OtoCoreReadState.loading) {
|
||||
_expandedArtifactsSnapshot = const OtoSurfaceSnapshot.loading();
|
||||
} else if (artifactsResult.state == OtoCoreReadState.empty || artifactsResult.data == null) {
|
||||
_expandedArtifactsSnapshot = const OtoSurfaceSnapshot.empty();
|
||||
} else {
|
||||
_expandedArtifactsSnapshot = OtoSurfaceSnapshot.data(
|
||||
artifactsResult.data!
|
||||
.map((e) => OtoArtifactViewModel(
|
||||
name: e.name,
|
||||
path: e.path,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
});
|
||||
} on Exception catch (e) {
|
||||
if (mounted && _expandedExecutionID == execID) {
|
||||
setState(() {
|
||||
_logsSnapshot = OtoSurfaceSnapshot.error(e.toString());
|
||||
_expandedArtifactsSnapshot = OtoSurfaceSnapshot.error(e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const themeAdapter = OtoConsoleThemeAdapter();
|
||||
|
|
@ -217,6 +513,12 @@ class _OtoClientAppState extends State<OtoClientApp> {
|
|||
config: widget.config,
|
||||
capabilities: widget.capabilities,
|
||||
themeAdapter: themeAdapter,
|
||||
initialSection: _activeSection,
|
||||
onNavigate: (section) {
|
||||
setState(() {
|
||||
_activeSection = section;
|
||||
});
|
||||
},
|
||||
overview: OtoConsoleOverview(
|
||||
config: widget.config,
|
||||
connection: _connection,
|
||||
|
|
@ -227,6 +529,33 @@ class _OtoClientAppState extends State<OtoClientApp> {
|
|||
snapshot: _runnersSnapshot,
|
||||
themeAdapter: themeAdapter,
|
||||
),
|
||||
pipelines: OtoJobsSurface(
|
||||
snapshot: _jobsSnapshot,
|
||||
themeAdapter: themeAdapter,
|
||||
onExecutionTap: (execID) {
|
||||
_loadExecutionDetail(execID);
|
||||
setState(() {
|
||||
_activeSection = OtoConsoleSection.executions;
|
||||
});
|
||||
},
|
||||
),
|
||||
executions: OtoExecutionsSurface(
|
||||
snapshot: _executionsSnapshot,
|
||||
expandedExecutionID: _expandedExecutionID,
|
||||
logsSnapshot: _logsSnapshot,
|
||||
artifactsSnapshot: _expandedArtifactsSnapshot,
|
||||
onExpandExecution: _loadExecutionDetail,
|
||||
onViewArtifactsSection: () {
|
||||
setState(() {
|
||||
_activeSection = OtoConsoleSection.artifacts;
|
||||
});
|
||||
},
|
||||
themeAdapter: themeAdapter,
|
||||
),
|
||||
artifacts: OtoArtifactsSurface(
|
||||
snapshot: _artifactsSnapshot,
|
||||
themeAdapter: themeAdapter,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,6 +244,8 @@ void main() {
|
|||
expect(find.textContaining('Job: job-999'), findsOneWidget);
|
||||
expect(find.textContaining('Execution: exec-888'), findsOneWidget);
|
||||
});
|
||||
|
||||
_registerJobsExecutionsTests();
|
||||
}
|
||||
|
||||
http.Response _jsonResponse(Map<String, Object?> body) {
|
||||
|
|
@ -283,9 +285,24 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
|
|||
onFetchRunner;
|
||||
final OtoCoreReadResult<OtoRunnerStatus> Function(String runnerID)?
|
||||
onFetchRunnerStatus;
|
||||
final OtoCoreReadResult<OtoJobRecord> Function(String jobID)?
|
||||
onFetchJob;
|
||||
final OtoCoreReadResult<OtoExecutionRecord> Function(String executionID)?
|
||||
onFetchExecution;
|
||||
final OtoCoreReadResult<List<OtoExecutionLogEntry>> Function(String executionID)?
|
||||
onFetchLogs;
|
||||
final OtoCoreReadResult<List<OtoArtifactRecord>> Function(String executionID)?
|
||||
onFetchArtifacts;
|
||||
OtoConsoleConfig? lastUsedConfig;
|
||||
|
||||
_FakeCoreReadClient({this.onFetchRunner, this.onFetchRunnerStatus});
|
||||
_FakeCoreReadClient({
|
||||
this.onFetchRunner,
|
||||
this.onFetchRunnerStatus,
|
||||
this.onFetchJob,
|
||||
this.onFetchExecution,
|
||||
this.onFetchLogs,
|
||||
this.onFetchArtifacts,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<OtoCoreReadResult<OtoRunnerRecord>> fetchRunner(
|
||||
|
|
@ -316,6 +333,10 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
|
|||
OtoConsoleConfig config,
|
||||
String jobID,
|
||||
) async {
|
||||
lastUsedConfig = config;
|
||||
if (onFetchJob != null) {
|
||||
return onFetchJob!(jobID);
|
||||
}
|
||||
return const OtoCoreReadResult.empty();
|
||||
}
|
||||
|
||||
|
|
@ -324,6 +345,10 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
|
|||
OtoConsoleConfig config,
|
||||
String executionID,
|
||||
) async {
|
||||
lastUsedConfig = config;
|
||||
if (onFetchExecution != null) {
|
||||
return onFetchExecution!(executionID);
|
||||
}
|
||||
return const OtoCoreReadResult.empty();
|
||||
}
|
||||
|
||||
|
|
@ -332,6 +357,10 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
|
|||
OtoConsoleConfig config,
|
||||
String executionID,
|
||||
) async {
|
||||
lastUsedConfig = config;
|
||||
if (onFetchLogs != null) {
|
||||
return onFetchLogs!(executionID);
|
||||
}
|
||||
return const OtoCoreReadResult.empty();
|
||||
}
|
||||
|
||||
|
|
@ -340,6 +369,218 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
|
|||
OtoConsoleConfig config,
|
||||
String executionID,
|
||||
) async {
|
||||
lastUsedConfig = config;
|
||||
if (onFetchArtifacts != null) {
|
||||
return onFetchArtifacts!(executionID);
|
||||
}
|
||||
return const OtoCoreReadResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
void _registerJobsExecutionsTests() {
|
||||
testWidgets('OtoClientApp wires jobs, executions and artifacts data state on API success', (
|
||||
tester,
|
||||
) async {
|
||||
final fakeReadClient = _FakeCoreReadClient(
|
||||
onFetchJob: (id) => OtoCoreReadResult.data(
|
||||
OtoJobRecord(
|
||||
id: id,
|
||||
name: 'Deploy Pipeline',
|
||||
state: 'running',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
executionID: 'exec-99',
|
||||
runRequest: null,
|
||||
),
|
||||
),
|
||||
onFetchExecution: (id) => OtoCoreReadResult.data(
|
||||
OtoExecutionRecord(
|
||||
id: id,
|
||||
jobID: 'job-77',
|
||||
state: 'succeeded',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
executionID: id,
|
||||
),
|
||||
),
|
||||
onFetchLogs: (id) => const OtoCoreReadResult.data([
|
||||
OtoExecutionLogEntry(timestamp: null, line: 'Task 1 done'),
|
||||
]),
|
||||
onFetchArtifacts: (id) => const OtoCoreReadResult.data([
|
||||
OtoArtifactRecord(name: 'release.tar.gz', path: '/out/release.tar.gz'),
|
||||
]),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
OtoClientApp(
|
||||
coreClient: _FakeCoreClient(),
|
||||
readClient: fakeReadClient,
|
||||
jobIDs: const ['job-77'],
|
||||
executionIDs: const ['exec-99'],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 1. Pipelines / Jobs Tab
|
||||
await tester.tap(find.byTooltip('Pipelines'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Deploy Pipeline'), findsOneWidget);
|
||||
expect(find.text('RUNNING'), findsOneWidget);
|
||||
expect(find.text('Job ID: job-77'), findsOneWidget);
|
||||
|
||||
// 2. Executions Tab
|
||||
await tester.tap(find.byTooltip('Executions'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Exec ID: exec-99'), findsOneWidget);
|
||||
expect(find.text('SUCCEEDED'), findsOneWidget);
|
||||
expect(find.text('Job ID: job-77'), findsOneWidget);
|
||||
|
||||
// Expand to see logs/artifacts
|
||||
await tester.tap(find.text('Exec ID: exec-99'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Logs Preview'), findsOneWidget);
|
||||
expect(find.textContaining('Task 1 done'), findsOneWidget);
|
||||
expect(find.text('release.tar.gz'), findsOneWidget);
|
||||
|
||||
// 3. Artifacts Tab
|
||||
await tester.tap(find.byTooltip('Artifacts'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('release.tar.gz'), findsOneWidget);
|
||||
expect(find.text('Path: /out/release.tar.gz'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoClientApp wires jobs, executions and artifacts empty states when no IDs', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
OtoClientApp(
|
||||
coreClient: _FakeCoreClient(),
|
||||
readClient: _FakeCoreReadClient(),
|
||||
jobIDs: const [],
|
||||
executionIDs: const [],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Pipelines Tab empty check
|
||||
await tester.tap(find.byTooltip('Pipelines'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No pipeline jobs'), findsOneWidget);
|
||||
expect(find.text('Job queue is empty.'), findsOneWidget);
|
||||
|
||||
// Executions Tab empty check
|
||||
await tester.tap(find.byTooltip('Executions'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No executions'), findsOneWidget);
|
||||
expect(find.text('Execution history is empty.'), findsOneWidget);
|
||||
|
||||
// Artifacts Tab empty check
|
||||
await tester.tap(find.byTooltip('Artifacts'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No artifacts'), findsOneWidget);
|
||||
expect(find.text('Artifact events are empty.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoClientApp wires jobs, executions and artifacts error states on API failure', (
|
||||
tester,
|
||||
) async {
|
||||
final fakeReadClient = _FakeCoreReadClient(
|
||||
onFetchJob: (id) => const OtoCoreReadResult.error('Failed to fetch job 1'),
|
||||
onFetchExecution: (id) => const OtoCoreReadResult.error('Failed to fetch exec 1'),
|
||||
onFetchArtifacts: (id) => const OtoCoreReadResult.error('Failed to fetch artifacts 1'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
OtoClientApp(
|
||||
coreClient: _FakeCoreClient(),
|
||||
readClient: fakeReadClient,
|
||||
jobIDs: const ['job-err'],
|
||||
executionIDs: const ['exec-err'],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Pipelines Tab error check
|
||||
await tester.tap(find.byTooltip('Pipelines'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Error loading jobs'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch job 1'), findsOneWidget);
|
||||
|
||||
// Executions Tab error check
|
||||
await tester.tap(find.byTooltip('Executions'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Error loading executions'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch exec 1'), findsOneWidget);
|
||||
|
||||
// Artifacts Tab error check
|
||||
await tester.tap(find.byTooltip('Artifacts'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Error loading artifacts'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch artifacts 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoClientApp wires expanded execution detail logs and artifacts empty/error states', (
|
||||
tester,
|
||||
) async {
|
||||
final executions = [
|
||||
OtoExecutionRecord(
|
||||
id: 'exec-detail',
|
||||
jobID: 'job-1',
|
||||
state: 'running',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
executionID: 'exec-detail',
|
||||
),
|
||||
];
|
||||
|
||||
bool shouldError = false;
|
||||
|
||||
final fakeReadClient = _FakeCoreReadClient(
|
||||
onFetchExecution: (id) => OtoCoreReadResult.data(executions.first),
|
||||
onFetchLogs: (id) => shouldError
|
||||
? const OtoCoreReadResult.error('Failed to load logs')
|
||||
: const OtoCoreReadResult.empty(data: []),
|
||||
onFetchArtifacts: (id) => shouldError
|
||||
? const OtoCoreReadResult.error('Failed to load artifacts')
|
||||
: const OtoCoreReadResult.empty(data: []),
|
||||
);
|
||||
|
||||
// 1. Render App and Expand to verify Empty State
|
||||
await tester.pumpWidget(
|
||||
OtoClientApp(
|
||||
coreClient: _FakeCoreClient(),
|
||||
readClient: fakeReadClient,
|
||||
executionIDs: const ['exec-detail'],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip('Executions'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Expand
|
||||
await tester.tap(find.text('Exec ID: exec-detail'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Log is empty.'), findsOneWidget);
|
||||
expect(find.text('No artifacts produced.'), findsOneWidget);
|
||||
|
||||
// Collapse
|
||||
await tester.tap(find.text('Exec ID: exec-detail'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Change to error state
|
||||
shouldError = true;
|
||||
|
||||
// Expand again to reload detail in error state
|
||||
await tester.tap(find.text('Exec ID: exec-detail'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Error loading logs: Failed to load logs'), findsOneWidget);
|
||||
expect(find.text('Error loading artifacts: Failed to load artifacts'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,3 +3,6 @@ export 'src/oto_console_contract.dart';
|
|||
export 'src/oto_console_overview.dart';
|
||||
export 'src/oto_console_shell.dart';
|
||||
export 'src/oto_runners_surface.dart';
|
||||
export 'src/oto_jobs_surface.dart';
|
||||
export 'src/oto_executions_surface.dart';
|
||||
export 'src/oto_artifacts_surface.dart';
|
||||
|
|
|
|||
216
packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart
Normal file
216
packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'oto_console_contract.dart';
|
||||
|
||||
class OtoArtifactsSurface extends StatelessWidget {
|
||||
final OtoSurfaceSnapshot<List<OtoArtifactViewModel>> snapshot;
|
||||
final OtoConsoleThemeAdapter? themeAdapter;
|
||||
|
||||
const OtoArtifactsSurface({
|
||||
super.key,
|
||||
required this.snapshot,
|
||||
this.themeAdapter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = themeAdapter ?? const OtoConsoleThemeAdapter();
|
||||
return Container(
|
||||
color: theme.backgroundColor,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 820),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
color: theme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Artifacts',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(child: _buildContent(context, theme)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, OtoConsoleThemeAdapter theme) {
|
||||
switch (snapshot.state) {
|
||||
case OtoSurfaceLoadState.loading:
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
case OtoSurfaceLoadState.empty:
|
||||
return _buildEmptyState(theme);
|
||||
case OtoSurfaceLoadState.error:
|
||||
return _buildErrorState(theme);
|
||||
case OtoSurfaceLoadState.data:
|
||||
final artifacts = snapshot.data ?? [];
|
||||
if (artifacts.isEmpty) {
|
||||
return _buildEmptyState(theme);
|
||||
}
|
||||
return _buildArtifactsList(context, theme, artifacts);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
color: theme.primaryColor,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'No artifacts',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Artifact events are empty.',
|
||||
style: TextStyle(color: theme.textSecondaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Error loading artifacts',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
snapshot.errorMessage ?? 'An unknown error occurred.',
|
||||
style: TextStyle(color: theme.textSecondaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtifactsList(
|
||||
BuildContext context,
|
||||
OtoConsoleThemeAdapter theme,
|
||||
List<OtoArtifactViewModel> artifacts,
|
||||
) {
|
||||
return ListView.separated(
|
||||
itemCount: artifacts.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final artifact = artifacts[index];
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.insert_drive_file_outlined,
|
||||
color: theme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
artifact.name,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Path: ${artifact.path}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -179,3 +179,59 @@ class OtoRunnerViewModel {
|
|||
required this.message,
|
||||
});
|
||||
}
|
||||
|
||||
class OtoJobViewModel {
|
||||
final String jobID;
|
||||
final String name;
|
||||
final String state;
|
||||
final String executionID;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
const OtoJobViewModel({
|
||||
required this.jobID,
|
||||
required this.name,
|
||||
required this.state,
|
||||
required this.executionID,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
class OtoExecutionViewModel {
|
||||
final String executionID;
|
||||
final String jobID;
|
||||
final String state;
|
||||
final String runnerID;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
const OtoExecutionViewModel({
|
||||
required this.executionID,
|
||||
required this.jobID,
|
||||
required this.state,
|
||||
required this.runnerID,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
class OtoLogEntryViewModel {
|
||||
final String timestamp;
|
||||
final String line;
|
||||
|
||||
const OtoLogEntryViewModel({
|
||||
required this.timestamp,
|
||||
required this.line,
|
||||
});
|
||||
}
|
||||
|
||||
class OtoArtifactViewModel {
|
||||
final String name;
|
||||
final String path;
|
||||
|
||||
const OtoArtifactViewModel({
|
||||
required this.name,
|
||||
required this.path,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ class OtoConsoleShell extends StatefulWidget {
|
|||
class _OtoConsoleShellState extends State<OtoConsoleShell> {
|
||||
late OtoConsoleSection _section = widget.initialSection;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant OtoConsoleShell oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.initialSection != widget.initialSection) {
|
||||
_section = widget.initialSection;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter();
|
||||
|
|
|
|||
499
packages/flutter/oto_console/lib/src/oto_executions_surface.dart
Normal file
499
packages/flutter/oto_console/lib/src/oto_executions_surface.dart
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'oto_console_contract.dart';
|
||||
|
||||
class OtoExecutionsSurface extends StatelessWidget {
|
||||
final OtoSurfaceSnapshot<List<OtoExecutionViewModel>> snapshot;
|
||||
final String? expandedExecutionID;
|
||||
final OtoSurfaceSnapshot<List<OtoLogEntryViewModel>>? logsSnapshot;
|
||||
final OtoSurfaceSnapshot<List<OtoArtifactViewModel>>? artifactsSnapshot;
|
||||
final ValueChanged<String?>? onExpandExecution;
|
||||
final VoidCallback? onViewArtifactsSection;
|
||||
final OtoConsoleThemeAdapter? themeAdapter;
|
||||
|
||||
const OtoExecutionsSurface({
|
||||
super.key,
|
||||
required this.snapshot,
|
||||
this.expandedExecutionID,
|
||||
this.logsSnapshot,
|
||||
this.artifactsSnapshot,
|
||||
this.onExpandExecution,
|
||||
this.onViewArtifactsSection,
|
||||
this.themeAdapter,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = themeAdapter ?? const OtoConsoleThemeAdapter();
|
||||
return Container(
|
||||
color: theme.backgroundColor,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 820),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: theme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Executions',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(child: _buildContent(context, theme)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, OtoConsoleThemeAdapter theme) {
|
||||
switch (snapshot.state) {
|
||||
case OtoSurfaceLoadState.loading:
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
case OtoSurfaceLoadState.empty:
|
||||
return _buildEmptyState(theme);
|
||||
case OtoSurfaceLoadState.error:
|
||||
return _buildErrorState(theme);
|
||||
case OtoSurfaceLoadState.data:
|
||||
final executions = snapshot.data ?? [];
|
||||
if (executions.isEmpty) {
|
||||
return _buildEmptyState(theme);
|
||||
}
|
||||
return _buildExecutionsList(context, theme, executions);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: theme.primaryColor,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'No executions',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Execution history is empty.',
|
||||
style: TextStyle(color: theme.textSecondaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Error loading executions',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
snapshot.errorMessage ?? 'An unknown error occurred.',
|
||||
style: TextStyle(color: theme.textSecondaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExecutionsList(
|
||||
BuildContext context,
|
||||
OtoConsoleThemeAdapter theme,
|
||||
List<OtoExecutionViewModel> executions,
|
||||
) {
|
||||
return ListView.separated(
|
||||
itemCount: executions.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final exec = executions[index];
|
||||
final isExpanded = expandedExecutionID == exec.executionID;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
if (isExpanded) {
|
||||
onExpandExecution?.call(null);
|
||||
} else {
|
||||
onExpandExecution?.call(exec.executionID);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Exec ID: ${exec.executionID}',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusBadge(state: exec.state, theme: theme),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Job ID: ${exec.jobID}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
if (exec.runnerID.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Runner ID: ${exec.runnerID}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Created: ${exec.createdAt}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
color: theme.textColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isExpanded) ...[
|
||||
const Divider(height: 1, thickness: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLogsSection(theme),
|
||||
const SizedBox(height: 16),
|
||||
_buildArtifactsSection(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogsSection(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Logs Preview',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
child: _buildLogsContent(theme),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogsContent(OtoConsoleThemeAdapter theme) {
|
||||
if (logsSnapshot == null) {
|
||||
return Text(
|
||||
'No logs loaded.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'),
|
||||
);
|
||||
}
|
||||
switch (logsSnapshot!.state) {
|
||||
case OtoSurfaceLoadState.loading:
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
case OtoSurfaceLoadState.empty:
|
||||
return Text(
|
||||
'Log is empty.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'),
|
||||
);
|
||||
case OtoSurfaceLoadState.error:
|
||||
return Text(
|
||||
'Error loading logs: ${logsSnapshot!.errorMessage}',
|
||||
style: const TextStyle(color: Colors.red, fontFamily: 'monospace'),
|
||||
);
|
||||
case OtoSurfaceLoadState.data:
|
||||
final logs = logsSnapshot!.data ?? [];
|
||||
if (logs.isEmpty) {
|
||||
return Text(
|
||||
'Log is empty.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: Text(
|
||||
logs.map((e) => '[${e.timestamp}] ${e.line}').join('\n'),
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildArtifactsSection(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Artifacts',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (onViewArtifactsSection != null)
|
||||
TextButton.icon(
|
||||
onPressed: onViewArtifactsSection,
|
||||
icon: const Icon(Icons.arrow_forward, size: 14),
|
||||
label: const Text('View Artifacts Tab'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.primaryColor,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildArtifactsContent(theme),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtifactsContent(OtoConsoleThemeAdapter theme) {
|
||||
if (artifactsSnapshot == null) {
|
||||
return Text(
|
||||
'No artifacts loaded.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
|
||||
);
|
||||
}
|
||||
switch (artifactsSnapshot!.state) {
|
||||
case OtoSurfaceLoadState.loading:
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
case OtoSurfaceLoadState.empty:
|
||||
return Text(
|
||||
'No artifacts produced.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
|
||||
);
|
||||
case OtoSurfaceLoadState.error:
|
||||
return Text(
|
||||
'Error loading artifacts: ${artifactsSnapshot!.errorMessage}',
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13),
|
||||
);
|
||||
case OtoSurfaceLoadState.data:
|
||||
final artifacts = artifactsSnapshot!.data ?? [];
|
||||
if (artifacts.isEmpty) {
|
||||
return Text(
|
||||
'No artifacts produced.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: artifacts.map((artifact) {
|
||||
return Card(
|
||||
color: theme.backgroundColor,
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
side: BorderSide(color: theme.borderColor),
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: Icon(Icons.insert_drive_file, color: theme.primaryColor, size: 16),
|
||||
title: Text(
|
||||
artifact.name,
|
||||
style: TextStyle(color: theme.textColor, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
artifact.path,
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 11),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final String state;
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
|
||||
const _StatusBadge({required this.state, required this.theme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color color;
|
||||
switch (state.toLowerCase()) {
|
||||
case 'queued':
|
||||
color = theme.textSecondaryColor;
|
||||
break;
|
||||
case 'running':
|
||||
color = theme.accentColor;
|
||||
break;
|
||||
case 'succeeded':
|
||||
color = theme.primaryColor;
|
||||
break;
|
||||
case 'failed':
|
||||
color = Colors.red;
|
||||
break;
|
||||
case 'canceled':
|
||||
color = Colors.orange;
|
||||
break;
|
||||
default:
|
||||
color = theme.textSecondaryColor;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
state.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
319
packages/flutter/oto_console/lib/src/oto_jobs_surface.dart
Normal file
319
packages/flutter/oto_console/lib/src/oto_jobs_surface.dart
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'oto_console_contract.dart';
|
||||
|
||||
class OtoJobsSurface extends StatelessWidget {
|
||||
final OtoSurfaceSnapshot<List<OtoJobViewModel>> snapshot;
|
||||
final OtoConsoleThemeAdapter? themeAdapter;
|
||||
final ValueChanged<String>? onExecutionTap;
|
||||
|
||||
const OtoJobsSurface({
|
||||
super.key,
|
||||
required this.snapshot,
|
||||
this.themeAdapter,
|
||||
this.onExecutionTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = themeAdapter ?? const OtoConsoleThemeAdapter();
|
||||
return Container(
|
||||
color: theme.backgroundColor,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 820),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_tree_outlined,
|
||||
color: theme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pipelines / Jobs',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(child: _buildContent(context, theme)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, OtoConsoleThemeAdapter theme) {
|
||||
switch (snapshot.state) {
|
||||
case OtoSurfaceLoadState.loading:
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
case OtoSurfaceLoadState.empty:
|
||||
return _buildEmptyState(theme);
|
||||
case OtoSurfaceLoadState.error:
|
||||
return _buildErrorState(theme);
|
||||
case OtoSurfaceLoadState.data:
|
||||
final jobs = snapshot.data ?? [];
|
||||
if (jobs.isEmpty) {
|
||||
return _buildEmptyState(theme);
|
||||
}
|
||||
return _buildJobsList(context, theme, jobs);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_tree_outlined,
|
||||
color: theme.primaryColor,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'No pipeline jobs',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Job queue is empty.',
|
||||
style: TextStyle(color: theme.textSecondaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Error loading jobs',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
snapshot.errorMessage ?? 'An unknown error occurred.',
|
||||
style: TextStyle(color: theme.textSecondaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJobsList(
|
||||
BuildContext context,
|
||||
OtoConsoleThemeAdapter theme,
|
||||
List<OtoJobViewModel> jobs,
|
||||
) {
|
||||
return ListView.separated(
|
||||
itemCount: jobs.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final job = jobs[index];
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
job.name,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusBadge(state: job.state, theme: theme),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Job ID: ${job.jobID}',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Created: ${job.createdAt}',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Updated: ${job.updatedAt}',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (job.executionID.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: () => onExecutionTap?.call(job.executionID),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.primaryColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: theme.primaryColor.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Execution: ${job.executionID}',
|
||||
style: TextStyle(
|
||||
color: theme.primaryColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final String state;
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
|
||||
const _StatusBadge({required this.state, required this.theme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color color;
|
||||
switch (state.toLowerCase()) {
|
||||
case 'queued':
|
||||
color = theme.textSecondaryColor;
|
||||
break;
|
||||
case 'running':
|
||||
color = theme.accentColor;
|
||||
break;
|
||||
case 'succeeded':
|
||||
color = theme.primaryColor;
|
||||
break;
|
||||
case 'failed':
|
||||
color = Colors.red;
|
||||
break;
|
||||
case 'canceled':
|
||||
color = Colors.orange;
|
||||
break;
|
||||
default:
|
||||
color = theme.textSecondaryColor;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
state.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -210,4 +210,153 @@ void main() {
|
|||
expect(find.text('OFFLINE'), findsOneWidget);
|
||||
expect(find.text('Message: Connection lost'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoJobsSurface renders loading/empty/error/data states', (tester) async {
|
||||
// Loading
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.loading()))));
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// Empty
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.empty()))));
|
||||
expect(find.text('No pipeline jobs'), findsOneWidget);
|
||||
expect(find.text('Job queue is empty.'), findsOneWidget);
|
||||
|
||||
// Error
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.error('Failed to fetch jobs')))));
|
||||
expect(find.text('Error loading jobs'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch jobs'), findsOneWidget);
|
||||
|
||||
// Data
|
||||
final jobs = [
|
||||
const OtoJobViewModel(
|
||||
jobID: 'job-1',
|
||||
name: 'Build Job',
|
||||
state: 'running',
|
||||
executionID: 'exec-1',
|
||||
createdAt: '2026-06-15T00:00:00Z',
|
||||
updatedAt: '2026-06-15T00:05:00Z',
|
||||
),
|
||||
];
|
||||
String? tappedExecution;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoJobsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(jobs),
|
||||
onExecutionTap: (id) => tappedExecution = id,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Build Job'), findsOneWidget);
|
||||
expect(find.text('RUNNING'), findsOneWidget);
|
||||
expect(find.text('Job ID: job-1'), findsOneWidget);
|
||||
expect(find.textContaining('Execution: exec-1'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.textContaining('Execution: exec-1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tappedExecution, 'exec-1');
|
||||
});
|
||||
|
||||
testWidgets('OtoExecutionsSurface renders loading/empty/error/data states and expanded detail', (tester) async {
|
||||
// Loading
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.loading()))));
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// Empty
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.empty()))));
|
||||
expect(find.text('No executions'), findsOneWidget);
|
||||
expect(find.text('Execution history is empty.'), findsOneWidget);
|
||||
|
||||
// Error
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.error('Failed to fetch executions')))));
|
||||
expect(find.text('Error loading executions'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch executions'), findsOneWidget);
|
||||
|
||||
// Data
|
||||
final executions = [
|
||||
const OtoExecutionViewModel(
|
||||
executionID: 'exec-1',
|
||||
jobID: 'job-1',
|
||||
state: 'succeeded',
|
||||
runnerID: 'runner-1',
|
||||
createdAt: '2026-06-15T00:01:00Z',
|
||||
updatedAt: '2026-06-15T00:05:00Z',
|
||||
),
|
||||
];
|
||||
String? expandedId;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoExecutionsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(executions),
|
||||
onExpandExecution: (id) => expandedId = id,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Exec ID: exec-1'), findsOneWidget);
|
||||
expect(find.text('SUCCEEDED'), findsOneWidget);
|
||||
expect(find.text('Job ID: job-1'), findsOneWidget);
|
||||
expect(find.text('Runner ID: runner-1'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Exec ID: exec-1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(expandedId, 'exec-1');
|
||||
|
||||
// Expanded detail rendering
|
||||
final logs = [
|
||||
const OtoLogEntryViewModel(timestamp: '12:00:00', line: 'Starting build...'),
|
||||
const OtoLogEntryViewModel(timestamp: '12:00:01', line: 'Finished successfully'),
|
||||
];
|
||||
final artifacts = [
|
||||
const OtoArtifactViewModel(name: 'binary.exe', path: '/build/binary.exe'),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoExecutionsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(executions),
|
||||
expandedExecutionID: 'exec-1',
|
||||
logsSnapshot: OtoSurfaceSnapshot.data(logs),
|
||||
artifactsSnapshot: OtoSurfaceSnapshot.data(artifacts),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Logs Preview'), findsOneWidget);
|
||||
expect(find.textContaining('Starting build...'), findsOneWidget);
|
||||
expect(find.text('Artifacts'), findsOneWidget);
|
||||
expect(find.text('binary.exe'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoArtifactsSurface renders loading/empty/error/data states', (tester) async {
|
||||
// Loading
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.loading()))));
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// Empty
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.empty()))));
|
||||
expect(find.text('No artifacts'), findsOneWidget);
|
||||
expect(find.text('Artifact events are empty.'), findsOneWidget);
|
||||
|
||||
// Error
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.error('Failed to fetch artifacts')))));
|
||||
expect(find.text('Error loading artifacts'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch artifacts'), findsOneWidget);
|
||||
|
||||
// Data
|
||||
final artifacts = [
|
||||
const OtoArtifactViewModel(name: 'app.apk', path: '/build/app.apk'),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.data(artifacts)),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('app.apk'), findsOneWidget);
|
||||
expect(find.text('Path: /build/app.apk'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue