- execution surface 및 preview smoke 테스트 문서 업데이트 - client 앱 및 oto_console UI surface (artifacts, executions, jobs) 추가 - 관련 테스트 및 계약 문서 업데이트 - archive 구조 정리
570 lines
18 KiB
Dart
570 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:oto_console/oto_console.dart';
|
|
|
|
import 'core_connection_client.dart';
|
|
|
|
const otoDefaultCapabilityPack = OtoCapabilityPack(
|
|
capabilities: [
|
|
OtoCapability(
|
|
name: 'Runner Registry',
|
|
description: 'Track connected build runners and bootstrap status.',
|
|
),
|
|
OtoCapability(
|
|
name: 'Remote Run',
|
|
description: 'Dispatch typed command build and deploy pipelines.',
|
|
),
|
|
OtoCapability(
|
|
name: 'Execution Status',
|
|
description: 'Follow active runs, cancellation, and terminal state.',
|
|
),
|
|
OtoCapability(
|
|
name: 'Step Logs',
|
|
description: 'Inspect live command output and execution traces.',
|
|
),
|
|
OtoCapability(
|
|
name: 'Artifacts',
|
|
description: 'Review build output events and published artifacts.',
|
|
),
|
|
],
|
|
);
|
|
|
|
class OtoClientApp extends StatefulWidget {
|
|
final OtoConsoleConfig config;
|
|
final OtoCoreConnectionSnapshot initialConnection;
|
|
final OtoCoreConnectionClient? coreClient;
|
|
final OtoCoreReadClient? readClient;
|
|
final List<String> runnerIDs;
|
|
final List<String> jobIDs;
|
|
final List<String> executionIDs;
|
|
final OtoCapabilityPack capabilities;
|
|
|
|
const OtoClientApp({
|
|
super.key,
|
|
this.config = const OtoConsoleConfig(
|
|
serverHttpUrl: String.fromEnvironment(
|
|
'OTO_SERVER_HTTP_URL',
|
|
defaultValue: 'http://localhost:8080',
|
|
),
|
|
serverWireUrl: String.fromEnvironment(
|
|
'OTO_SERVER_WIRE_URL',
|
|
defaultValue: 'ws://localhost:18080/runner',
|
|
),
|
|
),
|
|
this.initialConnection = const OtoCoreConnectionSnapshot.checking(),
|
|
this.coreClient,
|
|
this.readClient,
|
|
this.runnerIDs = const [],
|
|
this.jobIDs = const [],
|
|
this.executionIDs = const [],
|
|
this.capabilities = otoDefaultCapabilityPack,
|
|
});
|
|
|
|
@override
|
|
State<OtoClientApp> createState() => _OtoClientAppState();
|
|
}
|
|
|
|
class _OtoClientAppState extends State<OtoClientApp> {
|
|
late final OtoCoreConnectionClient _defaultCoreClient =
|
|
OtoHttpCoreConnectionClient();
|
|
late final OtoCoreReadClient _defaultReadClient = OtoHttpCoreReadClient();
|
|
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
|
|
void initState() {
|
|
super.initState();
|
|
_refreshCoreStatus();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant OtoClientApp oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.config.serverHttpUrl != widget.config.serverHttpUrl ||
|
|
oldWidget.coreClient != widget.coreClient ||
|
|
oldWidget.readClient != widget.readClient ||
|
|
!_listEquals(oldWidget.runnerIDs, widget.runnerIDs) ||
|
|
!_listEquals(oldWidget.jobIDs, widget.jobIDs) ||
|
|
!_listEquals(oldWidget.executionIDs, widget.executionIDs)) {
|
|
_refreshCoreStatus();
|
|
}
|
|
}
|
|
|
|
bool _listEquals(List<String>? a, List<String>? b) {
|
|
if (a == b) return true;
|
|
if (a == null || b == null) return false;
|
|
if (a.length != b.length) return false;
|
|
for (int i = 0; i < a.length; i++) {
|
|
if (a[i] != b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Future<void> _refreshCoreStatus() async {
|
|
final generation = ++_refreshGeneration;
|
|
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;
|
|
final readClient = widget.readClient ?? _defaultReadClient;
|
|
|
|
final Future<OtoCoreConnectionSnapshot> connectionFuture = coreClient.check(
|
|
widget.config,
|
|
);
|
|
|
|
final Future<OtoSurfaceSnapshot<List<OtoRunnerViewModel>>> runnersFuture =
|
|
_fetchRunners(readClient);
|
|
|
|
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;
|
|
}
|
|
|
|
setState(() {
|
|
_connection = connection;
|
|
_runnersSnapshot = runnersSnapshot;
|
|
_jobsSnapshot = jobsSnapshot;
|
|
_executionsSnapshot = executionsSnapshot;
|
|
_artifactsSnapshot = artifactsSnapshot;
|
|
});
|
|
|
|
if (_expandedExecutionID != null) {
|
|
_loadExecutionDetail(_expandedExecutionID);
|
|
}
|
|
}
|
|
|
|
Future<OtoSurfaceSnapshot<List<OtoRunnerViewModel>>> _fetchRunners(
|
|
OtoCoreReadClient readClient,
|
|
) async {
|
|
if (widget.runnerIDs.isEmpty) {
|
|
return const OtoSurfaceSnapshot.empty();
|
|
}
|
|
|
|
try {
|
|
final results = await Future.wait(
|
|
widget.runnerIDs.map((id) async {
|
|
final recordResult = await readClient.fetchRunner(widget.config, id);
|
|
final statusResult = await readClient.fetchRunnerStatus(
|
|
widget.config,
|
|
id,
|
|
);
|
|
return _RunnerFetchResult(id, recordResult, statusResult);
|
|
}),
|
|
);
|
|
|
|
final viewModels = <OtoRunnerViewModel>[];
|
|
String? errorMessage;
|
|
bool isLoading = false;
|
|
bool isEmpty = true;
|
|
|
|
for (final result in results) {
|
|
if (result.recordResult.state == OtoCoreReadState.error) {
|
|
errorMessage = result.recordResult.message;
|
|
break;
|
|
}
|
|
if (result.statusResult.state == OtoCoreReadState.error) {
|
|
errorMessage = result.statusResult.message;
|
|
break;
|
|
}
|
|
if (result.recordResult.state == OtoCoreReadState.loading ||
|
|
result.statusResult.state == OtoCoreReadState.loading) {
|
|
isLoading = true;
|
|
break;
|
|
}
|
|
if (result.recordResult.state == OtoCoreReadState.data) {
|
|
isEmpty = false;
|
|
final record = result.recordResult.data!;
|
|
final status = result.statusResult.state == OtoCoreReadState.data
|
|
? result.statusResult.data
|
|
: null;
|
|
viewModels.add(
|
|
OtoRunnerViewModel(
|
|
runnerID: record.runnerID,
|
|
alias: record.alias,
|
|
status: status?.status ?? record.status,
|
|
currentJobID: status?.currentJobID ?? '',
|
|
currentExecutionID: status?.currentExecutionID ?? '',
|
|
message: status?.message ?? record.failureReason,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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<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();
|
|
return MaterialApp(
|
|
title: 'OTO Client',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: ThemeData.dark(useMaterial3: true).copyWith(
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: themeAdapter.primaryColor,
|
|
brightness: Brightness.dark,
|
|
primary: themeAdapter.primaryColor,
|
|
secondary: themeAdapter.accentColor,
|
|
),
|
|
scaffoldBackgroundColor: themeAdapter.backgroundColor,
|
|
),
|
|
home: OtoConsoleShell(
|
|
config: widget.config,
|
|
capabilities: widget.capabilities,
|
|
themeAdapter: themeAdapter,
|
|
initialSection: _activeSection,
|
|
onNavigate: (section) {
|
|
setState(() {
|
|
_activeSection = section;
|
|
});
|
|
},
|
|
overview: OtoConsoleOverview(
|
|
config: widget.config,
|
|
connection: _connection,
|
|
onRefresh: _refreshCoreStatus,
|
|
themeAdapter: themeAdapter,
|
|
),
|
|
runners: OtoRunnersSurface(
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RunnerFetchResult {
|
|
final String id;
|
|
final OtoCoreReadResult<OtoRunnerRecord> recordResult;
|
|
final OtoCoreReadResult<OtoRunnerStatus> statusResult;
|
|
|
|
_RunnerFetchResult(this.id, this.recordResult, this.statusResult);
|
|
}
|