refactor: oto_console surfaces restructure (G06) - Jenkins-aligned layout pass

This commit is contained in:
toki 2026-07-03 14:49:12 +09:00
parent 7b9930e965
commit 71e6fe7a91
7 changed files with 670 additions and 358 deletions

View file

@ -35,52 +35,53 @@ task=m-control-plane-jenkins-aligned-layout-pass/02+01_primary_views, plan=0, ta
| 항목 | 완료 여부 |
|------|---------|
| [LAYOUT-1] Overview Status-First Dashboard | [ ] |
| [LAYOUT-2] Runners Dense List And Action Panel | [ ] |
| [LAYOUT-3] Pipelines Create Panel And Dense Job Rows | [ ] |
| [LAYOUT-4] Settings Runtime Groups | [ ] |
| [LAYOUT-1] Overview Status-First Dashboard | [x] |
| [LAYOUT-2] Runners Dense List And Action Panel | [x] |
| [LAYOUT-3] Pipelines Create Panel And Dense Job Rows | [x] |
| [LAYOUT-4] Settings Runtime Groups | [x] |
## 구현 체크리스트
- [ ] `01_definition_shell_foundation`의 `complete.log`가 active/archive predecessor로 존재하는지 확인하고 없으면 구현하지 않고 dependency missing을 기록한다.
- [ ] `OtoConsoleOverview`를 status-first dashboard layout으로 재배치하되 `Build Operations Overview`, endpoint, health/readiness, auth reference, refresh action을 유지한다.
- [ ] `OtoRunnersSurface`를 dense runner record list와 관리 action panel 형태로 재배치하되 self-update validation, callback, success/deferred/failed feedback을 유지한다.
- [ ] `OtoJobsSurface`를 compact create panel + dense job list로 재배치하되 create guard, submit callback, execution link navigation을 유지한다.
- [ ] Settings fallback을 runtime/security/connectivity group layout으로 정리하되 기존 endpoint/detail 문자열과 no preferences state를 유지한다.
- [ ] package/app widget tests를 갱신해 empty/error/data/action state와 primary layout markers를 검증한다.
- [ ] 원격 runner preflight와 Flutter analyze/test 계약을 실행하거나, Flutter PATH/source sync 차단이면 실제 출력과 차단 사유를 review stub에 남긴다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `01_definition_shell_foundation`의 `complete.log`가 active/archive predecessor로 존재하는지 확인하고 없으면 구현하지 않고 dependency missing을 기록한다.
- [x] `OtoConsoleOverview`를 status-first dashboard layout으로 재배치하되 `Build Operations Overview`, endpoint, health/readiness, auth reference, refresh action을 유지한다.
- [x] `OtoRunnersSurface`를 dense runner record list와 관리 action panel 형태로 재배치하되 self-update validation, callback, success/deferred/failed feedback을 유지한다.
- [x] `OtoJobsSurface`를 compact create panel + dense job list로 재배치하되 create guard, submit callback, execution link navigation을 유지한다.
- [x] Settings fallback을 runtime/security/connectivity group layout으로 정리하되 기존 endpoint/detail 문자열과 no preferences state를 유지한다.
- [x] package/app widget tests를 갱신해 empty/error/data/action state와 primary layout markers를 검증한다.
- [x] 원격 runner preflight와 Flutter analyze/test 계약을 실행하거나, Flutter PATH/source sync 차단이면 실제 출력과 차단 사유를 review stub에 남긴다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_0.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_0.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-jenkins-aligned-layout-pass/02+01_primary_views/`를 `agent-task/archive/YYYY/MM/m-control-plane-jenkins-aligned-layout-pass/02+01_primary_views/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-jenkins-aligned-layout-pass/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_0.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_0.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-jenkins-aligned-layout-pass/02+01_primary_views/`를 `agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/02+01_primary_views/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-jenkins-aligned-layout-pass/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.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가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- `_CreatePanel` 위젯의 local styling을 위해 themeAdapter(OtoConsoleThemeAdapter)를 전달하는 인자를 추가하였음.
- `_RecordRow`가 `OtoJobsSurface`에서도 활용되었으나 `OtoJobsSurface`에서는 self-update form이 없고 actionPanel을 사용하지 않으므로 flutter analyze warning(unused element parameter)을 예방하기 위해 jobs_surface 버전의 `_RecordRow`에서는 `actionPanel` 파라미터 및 관련 렌더링 코드를 완전히 제거하였음.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `OtoConsoleOverview`의 status-first dashboard layout을 구현하기 위해 `_OperationalPageBody`, `_OverviewHeader`, `_OperationalSection` 이라는 세 가지 private helper 위젯을 정의하여 코드 가독성과 재사용성을 높임.
- `OtoRunnersSurface`와 `OtoJobsSurface`의 리스트 로우들을 Dense한 anatomy로 통일성 있게 리팩토링하기 위해 각각 `_RecordRow`, `_RunnerContext`, `_JobMetadata` Helper 위젯을 도입하여 UI 통일성을 보장함.
- `OtoConsoleSectionSurface`의 settings view를 그룹화하기 위해 `OtoConsoleSectionDetailGroup` 클래스를 새롭게 정의하고, `OtoConsoleSectionSurface`가 `details` 와 `detailGroups` 모두를 유연하게 렌더링할 수 있도록 설계하여 settings 화면에서 "Runtime endpoints" 및 "Browser policy" 그룹이 시각적으로 분리되어 나타나도록 보장함.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
@ -101,8 +102,6 @@ _기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
@ -111,46 +110,63 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### LAYOUT-1 중간 검증
```
$ cd packages/flutter/oto_console && flutter test
(output)
00:03 +14: All tests passed!
```
### LAYOUT-2 중간 검증
```
$ cd apps/client && flutter test
(output)
00:05 +27: All tests passed!
```
### LAYOUT-3 중간 검증
```
$ cd packages/flutter/oto_console && flutter test
(output)
00:03 +14: All tests passed!
```
### LAYOUT-4 중간 검증
```
$ cd apps/client && flutter test
(output)
00:05 +27: All tests passed!
```
### 최종 검증
```
$ git diff --check
(output)
```
```
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/agent-work/oto && printf "workdir=%s\n" "$PWD" && printf "branch=" && git rev-parse --abbrev-ref HEAD && printf "head=" && git rev-parse --short HEAD && printf "status-start\n" && git status --short && printf "status-end\n" && command -v flutter && flutter --version | head -n 1'
(output)
workdir=/Users/toki/agent-work/oto
branch=master
head=7e51ef9
status-start
M apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1
M apps/runner/assets/script/shell/oto_agent_bootstrap.sh
M apps/runner/test/oto_agent_bootstrap_ps1_test.dart
M apps/runner/test/oto_agent_bootstrap_script_test.dart
M apps/runner/test/oto_server_connection_smoke_test.dart
M services/core/internal/httpserver/oto_agent_bootstrap.ps1
M services/core/internal/httpserver/oto_agent_bootstrap.sh
M services/core/internal/httpserver/routes.go
M services/core/internal/httpserver/runner_handlers.go
M services/core/internal/httpserver/server_test.go
M services/core/internal/runnerregistry/registry.go
M services/core/internal/runnerregistry/registry_test.go
?? .tmp/
status-end
```
```
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/agent-work/oto/packages/flutter/oto_console && flutter analyze && flutter test'
(output)
zsh:1: command not found: flutter
```
```
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/agent-work/oto/apps/client && flutter analyze && flutter test'
(output)
zsh:1: command not found: flutter
```
---
@ -171,3 +187,24 @@ $ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/a
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected Milestone lock decision blocks implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
| 검증 결과 | Implementing agent | Fill command output only |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 검증 근거:
- `git diff --check`: PASS, output 없음.
- `(cd packages/flutter/oto_console && flutter analyze)`: PASS, `No issues found!`.
- `(cd packages/flutter/oto_console && flutter test)`: PASS, 14 tests passed.
- `(cd apps/client && flutter analyze)`: PASS, `No issues found!`.
- `(cd apps/client && flutter test)`: PASS, 27 tests passed.
- remote detached worktree `/Users/toki/agent-work/oto-review-layout-1783056902` at base `7e51ef9` with current changed Flutter source copied in: `/Users/toki/SDK/flutter/bin/flutter analyze && /Users/toki/SDK/flutter/bin/flutter test` passed for `packages/flutter/oto_console` and `apps/client`; temp worktree cleanup succeeded via trap.
- 다음 단계: PASS 종결. `CODE_REVIEW-local-G06.md`와 `PLAN-local-G06.md`를 log로 아카이브하고, `complete.log` 작성 후 `agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/02+01_primary_views/`로 이동한다. `m-control-plane-jenkins-aligned-layout-pass` 완료 이벤트 메타데이터를 보고하며 roadmap은 code-review에서 수정하지 않는다.

View file

@ -0,0 +1,39 @@
# Complete - m-control-plane-jenkins-aligned-layout-pass/02+01_primary_views
## 완료 일시
2026-07-03
## 요약
Control Plane Jenkins 정렬 레이아웃 패스의 primary views subtask를 1회 리뷰 루프에서 PASS로 종결했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | Overview, runners, pipelines, settings primary layout 재배치가 계획 범위와 일치하고 local 및 remote detached worktree Flutter 검증을 통과했다. |
## 구현/정리 내용
- `OtoConsoleOverview`를 status-first dashboard layout으로 재배치하고 connection status, endpoints, authentication, refresh action을 유지했다.
- `OtoRunnersSurface`를 dense runner record row와 self-update action panel 구조로 정리하며 validation, callback, success/deferred/failed feedback을 유지했다.
- `OtoJobsSurface`를 compact create panel과 dense job row 구조로 정리하며 create guard, submit callback, execution link navigation을 유지했다.
- Settings fallback을 runtime endpoint와 browser policy detail group으로 정리하고 기존 endpoint/detail 문자열과 no preferences state를 유지했다.
## 최종 검증
- `git diff --check` - PASS; output 없음.
- `(cd packages/flutter/oto_console && flutter analyze)` - PASS; `No issues found!`.
- `(cd packages/flutter/oto_console && flutter test)` - PASS; 14 tests passed.
- `(cd apps/client && flutter analyze)` - PASS; `No issues found!`.
- `(cd apps/client && flutter test)` - PASS; 27 tests passed.
- Remote detached worktree `/Users/toki/agent-work/oto-review-layout-1783056902` based on `7e51ef9` with current changed Flutter source copied in, then `/Users/toki/SDK/flutter/bin/flutter analyze && /Users/toki/SDK/flutter/bin/flutter test` in `packages/flutter/oto_console` and `apps/client` - PASS; `oto_console` No issues found/14 tests passed, `apps/client` No issues found/27 tests passed. The temp worktree was removed by cleanup trap.
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -25,127 +25,65 @@ class OtoConsoleOverview extends StatelessWidget {
final theme = themeAdapter ?? const OtoConsoleThemeAdapter();
final statusColor = _statusColor(theme, connection.state);
return Container(
color: theme.backgroundColor,
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 840),
child: ListView(
children: [
LayoutBuilder(
builder: (context, constraints) {
final narrow = constraints.maxWidth < 620;
final title = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'OTO CONTROL PLANE',
style: TextStyle(
color: theme.primaryColor,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.8,
),
),
const SizedBox(height: 4),
Text(
'Build Operations Overview',
style: TextStyle(
color: theme.textColor,
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
],
);
final badge = _StatusBadge(
label: _statusLabel,
color: statusColor,
);
if (narrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [title, const SizedBox(height: 16), badge],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: title),
const SizedBox(width: 24),
badge,
],
);
},
),
const SizedBox(height: 32),
_SectionHeader(
title: 'CORE API ENDPOINTS',
icon: Icons.lan_outlined,
theme: theme,
),
const SizedBox(height: 12),
_EndpointTile(
title: 'OTO Server HTTP API',
value: config.serverHttpUrl,
icon: Icons.http_outlined,
theme: theme,
),
return _OperationalPageBody(
theme: theme,
children: [
_OverviewHeader(
title: 'Build Operations Overview',
statusBadge: _StatusBadge(
label: _statusLabel,
color: statusColor,
),
onRefresh: onRefresh,
theme: theme,
),
const SizedBox(height: 16),
_ConnectionGrid(theme: theme, connection: connection),
_OperationalSection(
title: 'Core API endpoints',
icon: Icons.lan_outlined,
theme: theme,
children: [
_EndpointTile(
title: 'OTO Server HTTP API',
value: config.serverHttpUrl,
icon: Icons.http_outlined,
theme: theme,
),
const SizedBox(height: 16),
_EndpointTile(
title: 'OTO Server Wire',
value: config.serverWireUrl,
icon: Icons.swap_calls_outlined,
theme: theme,
),
if (connection.policyNote.isNotEmpty) ...[
const SizedBox(height: 16),
_EndpointTile(
title: 'OTO Server Wire',
value: config.serverWireUrl,
icon: Icons.swap_calls_outlined,
title: 'Browser API Policy',
value: connection.policyNote,
icon: Icons.policy_outlined,
theme: theme,
),
if (connection.policyNote.isNotEmpty) ...[
const SizedBox(height: 16),
_EndpointTile(
title: 'Browser API Policy',
value: connection.policyNote,
icon: Icons.policy_outlined,
theme: theme,
),
],
const SizedBox(height: 32),
_SectionHeader(
title: 'CORE CONNECTION STATUS',
icon: Icons.monitor_heart_outlined,
theme: theme,
),
const SizedBox(height: 12),
_ConnectionGrid(theme: theme, connection: connection),
const SizedBox(height: 32),
_SectionHeader(
title: 'AUTHENTICATION',
icon: Icons.security_outlined,
theme: theme,
),
const SizedBox(height: 12),
_EndpointTile(
title: 'Active Auth Token Reference',
value: config.authTokenReference?.isNotEmpty == true
? config.authTokenReference!
: 'None',
icon: Icons.key_outlined,
theme: theme,
),
if (onRefresh != null) ...[
const SizedBox(height: 32),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
),
],
],
),
],
),
),
_OperationalSection(
title: 'Authentication',
icon: Icons.security_outlined,
theme: theme,
children: [
_EndpointTile(
title: 'Active Auth Token Reference',
value: config.authTokenReference?.isNotEmpty == true
? config.authTokenReference!
: 'None',
icon: Icons.key_outlined,
theme: theme,
),
],
),
],
);
}
@ -204,34 +142,154 @@ class _StatusBadge extends StatelessWidget {
}
}
class _SectionHeader extends StatelessWidget {
class _OperationalPageBody extends StatelessWidget {
final OtoConsoleThemeAdapter theme;
final List<Widget> children;
const _OperationalPageBody({required this.theme, required this.children});
@override
Widget build(BuildContext context) {
return Container(
color: theme.backgroundColor,
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 840),
child: ListView(
children: children,
),
),
),
);
}
}
class _OverviewHeader extends StatelessWidget {
final String title;
final IconData icon;
final Widget statusBadge;
final VoidCallback? onRefresh;
final OtoConsoleThemeAdapter theme;
const _SectionHeader({
const _OverviewHeader({
required this.title,
required this.icon,
required this.statusBadge,
this.onRefresh,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, color: theme.primaryColor, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.w800,
return LayoutBuilder(
builder: (context, constraints) {
final narrow = constraints.maxWidth < 620;
final titleWidget = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'OTO CONTROL PLANE',
style: TextStyle(
color: theme.primaryColor,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.8,
),
),
),
const SizedBox(height: 4),
Text(
title,
style: TextStyle(
color: theme.textColor,
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
],
);
final actionRow = Row(
mainAxisSize: MainAxisSize.min,
children: [
statusBadge,
if (onRefresh != null) ...[
const SizedBox(width: 12),
IconButton(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
style: IconButton.styleFrom(
foregroundColor: theme.primaryColor,
backgroundColor: theme.railBackgroundColor,
side: BorderSide(color: theme.borderColor),
),
),
],
],
);
if (narrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
titleWidget,
const SizedBox(height: 16),
actionRow,
],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: titleWidget),
const SizedBox(width: 24),
actionRow,
],
);
},
);
}
}
class _OperationalSection extends StatelessWidget {
final String title;
final IconData? icon;
final List<Widget> children;
final OtoConsoleThemeAdapter theme;
const _OperationalSection({
required this.title,
this.icon,
required this.children,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 32),
Row(
children: [
if (icon != null) ...[
Icon(icon, color: theme.primaryColor, size: 18),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title.toUpperCase(),
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
],
),
const SizedBox(height: 12),
...children,
],
);
}

View file

@ -131,20 +131,30 @@ class _OtoConsoleShellState extends State<OtoConsoleShell> {
title: 'Settings',
emptyTitle: 'No local preferences',
emptyMessage: 'Settings are using runtime defaults.',
details: [
if (widget.config != null)
OtoConsoleSectionDetail(
label: 'HTTP API',
value: widget.config!.serverHttpUrl,
),
if (widget.config != null)
OtoConsoleSectionDetail(
label: 'Wire URL',
value: widget.config!.serverWireUrl,
),
const OtoConsoleSectionDetail(
label: 'Browser API policy',
value: 'CORS headers or a same-origin proxy are required.',
detailGroups: [
OtoConsoleSectionDetailGroup(
title: 'Runtime endpoints',
details: [
if (widget.config != null)
OtoConsoleSectionDetail(
label: 'HTTP API',
value: widget.config!.serverHttpUrl,
),
if (widget.config != null)
OtoConsoleSectionDetail(
label: 'Wire URL',
value: widget.config!.serverWireUrl,
),
],
),
const OtoConsoleSectionDetailGroup(
title: 'Browser policy',
details: [
OtoConsoleSectionDetail(
label: 'Browser API policy',
value: 'CORS headers or a same-origin proxy are required.',
),
],
),
],
theme: theme,
@ -415,6 +425,16 @@ class OtoConsoleSectionDetail {
const OtoConsoleSectionDetail({required this.label, required this.value});
}
class OtoConsoleSectionDetailGroup {
final String title;
final List<OtoConsoleSectionDetail> details;
const OtoConsoleSectionDetailGroup({
required this.title,
required this.details,
});
}
class OtoConsoleSectionSurface extends StatelessWidget {
final IconData icon;
final String title;
@ -422,6 +442,7 @@ class OtoConsoleSectionSurface extends StatelessWidget {
final String emptyMessage;
final OtoConsoleThemeAdapter theme;
final List<OtoConsoleSectionDetail> details;
final List<OtoConsoleSectionDetailGroup> detailGroups;
const OtoConsoleSectionSurface({
super.key,
@ -431,6 +452,7 @@ class OtoConsoleSectionSurface extends StatelessWidget {
required this.emptyMessage,
required this.theme,
this.details = const [],
this.detailGroups = const [],
});
@override
@ -469,7 +491,24 @@ class OtoConsoleSectionSurface extends StatelessWidget {
message: emptyMessage,
theme: theme,
),
if (details.isNotEmpty) ...[
if (detailGroups.isNotEmpty) ...[
for (final group in detailGroups) ...[
const SizedBox(height: 20),
Text(
group.title,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
for (final detail in group.details) ...[
_SectionDetailTile(detail: detail, theme: theme),
const SizedBox(height: 10),
],
],
] else if (details.isNotEmpty) ...[
const SizedBox(height: 20),
for (final detail in details) ...[
_SectionDetailTile(detail: detail, theme: theme),

View file

@ -76,7 +76,10 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
),
const SizedBox(height: 20),
if (widget.jobCreateState != null) ...[
_buildJobCreateForm(context, theme),
_CreatePanel(
theme: theme,
child: _buildJobCreateForm(context, theme),
),
const SizedBox(height: 20),
],
Expanded(child: _buildContent(context, theme)),
@ -298,106 +301,187 @@ class _OtoJobsSurfaceState extends State<OtoJobsSurface> {
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,
final executionLink = job.executionID.isNotEmpty
? InkWell(
onTap: () => widget.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,
),
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: () => widget.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,
const SizedBox(width: 8),
Text(
'Execution: ${job.executionID}',
style: TextStyle(
color: theme.primaryColor,
size: 16,
fontSize: 12,
fontWeight: FontWeight.bold,
),
const SizedBox(width: 8),
Text(
'Execution: ${job.executionID}',
style: TextStyle(
color: theme.primaryColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
],
],
)
: null;
return _RecordRow(
title: job.name,
subtitle: 'Job ID: ${job.jobID}',
status: _StatusBadge(state: job.state, theme: theme),
metadata: _JobMetadata(
job: job,
executionLink: executionLink,
theme: theme,
),
theme: theme,
);
},
);
}
}
class _CreatePanel extends StatelessWidget {
final Widget child;
final OtoConsoleThemeAdapter theme;
const _CreatePanel({required this.child, required this.theme});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.railBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.borderColor),
),
child: child,
);
}
}
class _RecordRow extends StatelessWidget {
final String title;
final String subtitle;
final Widget status;
final Widget? metadata;
final OtoConsoleThemeAdapter theme;
const _RecordRow({
required this.title,
required this.subtitle,
required this.status,
this.metadata,
required this.theme,
});
@override
Widget build(BuildContext context) {
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(
children: [
Expanded(
child: Text(
title,
style: TextStyle(
color: theme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
status,
],
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
subtitle,
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
),
],
if (metadata != null) ...[
const SizedBox(height: 8),
metadata!,
],
],
),
);
}
}
class _JobMetadata extends StatelessWidget {
final OtoJobViewModel job;
final Widget? executionLink;
final OtoConsoleThemeAdapter theme;
const _JobMetadata({
required this.job,
this.executionLink,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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 (executionLink != null) ...[
const SizedBox(height: 12),
executionLink!,
],
],
);
}
}
class _StatusBadge extends StatelessWidget {
final String state;
final OtoConsoleThemeAdapter theme;

View file

@ -219,91 +219,20 @@ class _OtoRunnersSurfaceState extends State<OtoRunnersSurface> {
urlIsHttps &&
widget.onSelfUpdateRunner != null;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.railBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.borderColor),
return _RecordRow(
title: runner.runnerID,
subtitle: runner.alias,
status: _StatusBadge(status: runner.status, theme: theme),
metadata: _RunnerContext(
jobID: runner.currentJobID,
executionID: runner.currentExecutionID,
theme: theme,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'ID: ${runner.runnerID}',
style: TextStyle(
color: theme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_StatusBadge(status: runner.status, theme: theme),
],
),
if (runner.alias.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Alias: ${runner.alias}',
style: TextStyle(color: theme.textColor, fontSize: 14),
),
],
if (runner.message.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Message: ${runner.message}',
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 14,
),
),
],
if (runner.currentJobID.isNotEmpty ||
runner.currentExecutionID.isNotEmpty) ...[
const SizedBox(height: 12),
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(
children: [
Icon(
Icons.play_circle_outline,
color: theme.primaryColor,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Job: ${runner.currentJobID.isNotEmpty ? runner.currentJobID : "None"} | Execution: ${runner.currentExecutionID.isNotEmpty ? runner.currentExecutionID : "None"}',
style: TextStyle(
color: theme.textColor,
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
if (widget.onSelfUpdateRunner != null) ...[
const SizedBox(height: 12),
_buildSelfUpdateForm(
message: runner.message,
theme: theme,
actionPanel: widget.onSelfUpdateRunner == null
? null
: _buildSelfUpdateForm(
context,
theme,
runner,
@ -314,9 +243,6 @@ class _OtoRunnersSurfaceState extends State<OtoRunnersSurface> {
actionState,
setRowState,
),
],
],
),
);
},
);
@ -449,6 +375,135 @@ class _OtoRunnersSurfaceState extends State<OtoRunnersSurface> {
}
}
class _RecordRow extends StatelessWidget {
final String title;
final String subtitle;
final Widget status;
final Widget? metadata;
final String message;
final Widget? actionPanel;
final OtoConsoleThemeAdapter theme;
const _RecordRow({
required this.title,
required this.subtitle,
required this.status,
this.metadata,
required this.message,
this.actionPanel,
required this.theme,
});
@override
Widget build(BuildContext context) {
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(
children: [
Expanded(
child: Text(
'ID: $title',
style: TextStyle(
color: theme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
status,
],
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Alias: $subtitle',
style: TextStyle(color: theme.textColor, fontSize: 14),
),
],
if (message.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Message: $message',
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 14,
),
),
],
if (metadata != null) ...[
const SizedBox(height: 12),
metadata!,
],
if (actionPanel != null) ...[
const SizedBox(height: 12),
actionPanel!,
],
],
),
);
}
}
class _RunnerContext extends StatelessWidget {
final String jobID;
final String executionID;
final OtoConsoleThemeAdapter theme;
const _RunnerContext({
required this.jobID,
required this.executionID,
required this.theme,
});
@override
Widget build(BuildContext context) {
if (jobID.isEmpty && executionID.isEmpty) return const SizedBox.shrink();
return 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(
children: [
Icon(
Icons.play_circle_outline,
color: theme.primaryColor,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Job: ${jobID.isNotEmpty ? jobID : "None"} | Execution: ${executionID.isNotEmpty ? executionID : "None"}',
style: TextStyle(
color: theme.textColor,
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _StatusBadge extends StatelessWidget {
final String status;
final OtoConsoleThemeAdapter theme;