oto/apps/client/lib/src/app/oto_client_app.dart

783 lines
25 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 OtoCoreWriteClient? writeClient;
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.writeClient,
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 final OtoCoreWriteClient _defaultWriteClient = OtoHttpCoreWriteClient();
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;
bool _showJobCreateForm = false;
int _refreshGeneration = 0;
final Set<String> _createdJobIDs = <String>{};
OtoActionViewState _jobCreateState = const OtoActionViewState.idle();
bool _isSubmittingJob = false;
final Map<String, OtoActionViewState> _executionActionStates =
<String, OtoActionViewState>{};
final Map<String, OtoRunnerActionState> _runnerActionStates =
<String, OtoRunnerActionState>{};
@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 ||
oldWidget.writeClient != widget.writeClient ||
!_listEquals(oldWidget.runnerIDs, widget.runnerIDs) ||
!_listEquals(oldWidget.jobIDs, widget.jobIDs) ||
!_listEquals(oldWidget.executionIDs, widget.executionIDs)) {
_refreshCoreStatus();
}
}
Future<void> _submitJobCreate(OtoJobCreateDraft draft) async {
if (_isSubmittingJob) return;
setState(() {
_isSubmittingJob = true;
_jobCreateState = const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
final result = await writeClient.createJob(widget.config, draft);
if (!mounted) return;
setState(() {
_isSubmittingJob = false;
if (result.isSuccess) {
_createdJobIDs.add(result.data!.id);
_jobCreateState = OtoActionViewState.succeeded(message: result.message);
_refreshCoreStatus();
} else {
_jobCreateState = OtoActionViewState.failed(message: result.message);
}
});
}
Future<void> _submitRunnerSelfUpdate(OtoRunnerSelfUpdateDraft draft) async {
setState(() {
_runnerActionStates[draft.runnerID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
final result = await writeClient.selfUpdateRunner(widget.config, draft);
if (!mounted) return;
final accepted =
result.state == OtoCoreWriteState.succeeded ||
result.state == OtoCoreWriteState.deferred;
setState(() {
if (result.state == OtoCoreWriteState.deferred) {
_runnerActionStates[draft.runnerID] = OtoActionViewState.deferred(
message: result.message,
);
} else if (accepted) {
_runnerActionStates[draft.runnerID] = OtoActionViewState.succeeded(
message: result.message,
);
} else {
_runnerActionStates[draft.runnerID] = OtoActionViewState.failed(
message: result.message,
);
}
});
if (accepted) {
_refreshCoreStatus();
}
}
Future<void> _submitCancelExecution(OtoExecutionCancelDraft draft) async {
setState(() {
_executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
final result = await writeClient.cancelExecution(widget.config, draft);
if (!mounted) return;
_handleExecutionActionResult(draft.executionID, result);
}
Future<void> _submitReportExecution(OtoExecutionReportDraft draft) async {
setState(() {
_executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
final result = await writeClient.reportExecution(widget.config, draft);
if (!mounted) return;
_handleExecutionActionResult(draft.executionID, result);
}
Future<void> _submitAppendExecutionLog(OtoExecutionLogDraft draft) async {
setState(() {
_executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
final result = await writeClient.appendExecutionLog(widget.config, draft);
if (!mounted) return;
_handleExecutionActionResult(draft.executionID, result);
}
Future<void> _submitAppendExecutionArtifact(
OtoExecutionArtifactDraft draft,
) async {
setState(() {
_executionActionStates[draft.executionID] =
const OtoActionViewState.submitting();
});
final writeClient = widget.writeClient ?? _defaultWriteClient;
final result = await writeClient.appendExecutionArtifact(
widget.config,
draft,
);
if (!mounted) return;
_handleExecutionActionResult(draft.executionID, result);
}
void _handleExecutionActionResult(
String executionID,
OtoCoreWriteResult<void> result,
) {
if (!mounted) return;
// Execution actions return `OtoCoreWriteResult<void>.succeeded(null, ...)` or `.deferred(...)`.
// `result.isSuccess` requires `data != null`, so check state directly.
final accepted =
result.state == OtoCoreWriteState.succeeded ||
result.state == OtoCoreWriteState.deferred;
if (accepted) {
setState(() {
_executionActionStates[executionID] = OtoActionViewState.succeeded(
message: result.message,
);
});
_refreshCoreStatus();
if (_expandedExecutionID == executionID) {
_loadExecutionDetail(executionID);
}
} else {
setState(() {
_executionActionStates[executionID] = OtoActionViewState.failed(
message: result.message,
);
});
}
}
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 && _createdJobIDs.isEmpty) {
return const OtoSurfaceSnapshot.empty();
}
final allJobIDs = <String>{...widget.jobIDs, ..._createdJobIDs};
try {
final results = await Future.wait(
allJobIDs.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 {
// Build executionID -> runnerID map from runner status.
Map<String, String> runnerIDByExecution = <String, String>{};
if (widget.runnerIDs.isNotEmpty) {
final runnerStatusResults = await Future.wait(
widget.runnerIDs.map(
(rid) => readClient.fetchRunnerStatus(widget.config, rid),
),
);
for (final statusResult in runnerStatusResults) {
if (statusResult.state == OtoCoreReadState.data &&
statusResult.data != null) {
final status = statusResult.data!;
if (status.currentExecutionID.isNotEmpty) {
runnerIDByExecution[status.currentExecutionID] = status.runnerID;
}
}
}
}
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: runnerIDByExecution[record.id] ?? '',
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;
_showJobCreateForm = false;
});
},
onNewItem: () {
setState(() {
_activeSection = OtoConsoleSection.pipelines;
_showJobCreateForm = true;
});
},
overview: OtoConsoleOverview(
config: widget.config,
connection: _connection,
onRefresh: _refreshCoreStatus,
themeAdapter: themeAdapter,
),
runners: OtoRunnersSurface(
snapshot: _runnersSnapshot,
actionStates: _runnerActionStates,
onSelfUpdateRunner: _submitRunnerSelfUpdate,
themeAdapter: themeAdapter,
),
pipelines: OtoJobsSurface(
snapshot: _jobsSnapshot,
themeAdapter: themeAdapter,
jobCreateState: _jobCreateState,
showCreateForm: _showJobCreateForm,
createOnly: _showJobCreateForm,
onCreateJob: _submitJobCreate,
onExecutionTap: (execID) {
_loadExecutionDetail(execID);
setState(() {
_activeSection = OtoConsoleSection.executions;
_showJobCreateForm = false;
});
},
),
executions: OtoExecutionsSurface(
snapshot: _executionsSnapshot,
expandedExecutionID: _expandedExecutionID,
actionStates: _executionActionStates,
logsSnapshot: _logsSnapshot,
artifactsSnapshot: _expandedArtifactsSnapshot,
onExpandExecution: _loadExecutionDetail,
onViewArtifactsSection: () {
setState(() {
_activeSection = OtoConsoleSection.artifacts;
_showJobCreateForm = false;
});
},
onCancelExecution: _submitCancelExecution,
onReportExecution: _submitReportExecution,
onAppendLog: _submitAppendExecutionLog,
onAppendArtifact: _submitAppendExecutionArtifact,
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);
}