623 lines
18 KiB
Dart
623 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'oto_agent_panel.dart';
|
|
import 'oto_console_contract.dart';
|
|
|
|
class OtoConsoleShell extends StatefulWidget {
|
|
final Widget overview;
|
|
final Widget? runners;
|
|
final Widget? pipelines;
|
|
final Widget? executions;
|
|
final Widget? artifacts;
|
|
final Widget? agent;
|
|
final Widget? settings;
|
|
final OtoConsoleSection initialSection;
|
|
final OtoConsoleConfig? config;
|
|
final OtoConsoleThemeAdapter? themeAdapter;
|
|
final OtoConsoleNavigationCallback? onNavigate;
|
|
final OtoCapabilityPack? capabilities;
|
|
|
|
const OtoConsoleShell({
|
|
super.key,
|
|
required this.overview,
|
|
this.runners,
|
|
this.pipelines,
|
|
this.executions,
|
|
this.artifacts,
|
|
this.agent,
|
|
this.settings,
|
|
this.initialSection = OtoConsoleSection.overview,
|
|
this.config,
|
|
this.themeAdapter,
|
|
this.onNavigate,
|
|
this.capabilities,
|
|
});
|
|
|
|
@override
|
|
State<OtoConsoleShell> createState() => _OtoConsoleShellState();
|
|
}
|
|
|
|
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();
|
|
final descriptor = _SectionDescriptor.forSection(_section);
|
|
return Scaffold(
|
|
backgroundColor: theme.backgroundColor,
|
|
body: SafeArea(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_OtoConsoleRail(
|
|
selected: _section,
|
|
onSelect: _select,
|
|
theme: theme,
|
|
),
|
|
Expanded(
|
|
child: _ConsolePageFrame(
|
|
descriptor: descriptor,
|
|
theme: theme,
|
|
child: _buildContent(theme),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _select(OtoConsoleSection section) {
|
|
setState(() {
|
|
_section = section;
|
|
});
|
|
widget.onNavigate?.call(section);
|
|
}
|
|
|
|
Widget _buildContent(OtoConsoleThemeAdapter theme) {
|
|
return switch (_section) {
|
|
OtoConsoleSection.overview => widget.overview,
|
|
OtoConsoleSection.runners =>
|
|
widget.runners ??
|
|
OtoConsoleSectionSurface(
|
|
icon: Icons.precision_manufacturing_outlined,
|
|
title: 'Runners',
|
|
emptyTitle: 'No runners connected',
|
|
emptyMessage: 'Runner registry is empty.',
|
|
theme: theme,
|
|
),
|
|
OtoConsoleSection.pipelines =>
|
|
widget.pipelines ??
|
|
OtoConsoleSectionSurface(
|
|
icon: Icons.account_tree_outlined,
|
|
title: 'Pipelines / Jobs',
|
|
emptyTitle: 'No pipeline jobs',
|
|
emptyMessage: 'Job queue is empty.',
|
|
theme: theme,
|
|
),
|
|
OtoConsoleSection.executions =>
|
|
widget.executions ??
|
|
OtoConsoleSectionSurface(
|
|
icon: Icons.play_circle_outline,
|
|
title: 'Executions',
|
|
emptyTitle: 'No executions',
|
|
emptyMessage: 'Execution history is empty.',
|
|
theme: theme,
|
|
),
|
|
OtoConsoleSection.artifacts =>
|
|
widget.artifacts ??
|
|
OtoConsoleSectionSurface(
|
|
icon: Icons.inventory_2_outlined,
|
|
title: 'Artifacts',
|
|
emptyTitle: 'No artifacts',
|
|
emptyMessage: 'Artifact events are empty.',
|
|
theme: theme,
|
|
),
|
|
OtoConsoleSection.agent =>
|
|
widget.agent ?? OtoAgentPanel(capabilities: widget.capabilities),
|
|
OtoConsoleSection.settings =>
|
|
widget.settings ??
|
|
OtoConsoleSectionSurface(
|
|
icon: Icons.tune,
|
|
title: 'Settings',
|
|
emptyTitle: 'No local preferences',
|
|
emptyMessage: 'Settings are using runtime defaults.',
|
|
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,
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
class _SectionDescriptor {
|
|
final OtoConsoleSection section;
|
|
final String label;
|
|
final String group;
|
|
|
|
const _SectionDescriptor({
|
|
required this.section,
|
|
required this.label,
|
|
required this.group,
|
|
});
|
|
|
|
static _SectionDescriptor forSection(OtoConsoleSection section) {
|
|
return switch (section) {
|
|
OtoConsoleSection.overview => _SectionDescriptor(
|
|
section: section,
|
|
label: 'Overview',
|
|
group: 'overview',
|
|
),
|
|
OtoConsoleSection.pipelines || OtoConsoleSection.executions =>
|
|
_SectionDescriptor(
|
|
section: section,
|
|
label: section == OtoConsoleSection.pipelines
|
|
? 'Pipelines'
|
|
: 'Executions',
|
|
group: 'workflow',
|
|
),
|
|
OtoConsoleSection.runners || OtoConsoleSection.artifacts =>
|
|
_SectionDescriptor(
|
|
section: section,
|
|
label: section == OtoConsoleSection.runners
|
|
? 'Runners'
|
|
: 'Artifacts',
|
|
group: 'operations',
|
|
),
|
|
OtoConsoleSection.agent => _SectionDescriptor(
|
|
section: section,
|
|
label: 'Agent',
|
|
group: 'admin',
|
|
),
|
|
OtoConsoleSection.settings => _SectionDescriptor(
|
|
section: section,
|
|
label: 'Settings',
|
|
group: 'admin',
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
class _ConsolePageFrame extends StatelessWidget {
|
|
final _SectionDescriptor descriptor;
|
|
final OtoConsoleThemeAdapter theme;
|
|
final Widget child;
|
|
|
|
const _ConsolePageFrame({
|
|
required this.descriptor,
|
|
required this.theme,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_PageHeader(descriptor: descriptor, theme: theme),
|
|
Expanded(child: child),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PageHeader extends StatelessWidget {
|
|
final _SectionDescriptor descriptor;
|
|
final OtoConsoleThemeAdapter theme;
|
|
|
|
const _PageHeader({
|
|
required this.descriptor,
|
|
required this.theme,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
|
color: theme.railBackgroundColor,
|
|
child: Row(
|
|
children: [
|
|
Icon(descriptor.section == OtoConsoleSection.overview
|
|
? Icons.dashboard_outlined
|
|
: descriptor.section == OtoConsoleSection.runners
|
|
? Icons.precision_manufacturing_outlined
|
|
: descriptor.section == OtoConsoleSection.pipelines
|
|
? Icons.account_tree_outlined
|
|
: descriptor.section == OtoConsoleSection.executions
|
|
? Icons.play_circle_outline
|
|
: descriptor.section == OtoConsoleSection.artifacts
|
|
? Icons.inventory_2_outlined
|
|
: descriptor.section == OtoConsoleSection.agent
|
|
? Icons.smart_toy_outlined
|
|
: Icons.tune,
|
|
color: theme.primaryColor,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
descriptor.label,
|
|
style: TextStyle(
|
|
color: theme.textColor,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RailDivider extends StatelessWidget {
|
|
const _RailDivider();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Container(
|
|
height: 1,
|
|
color: Colors.white38,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OtoConsoleRail extends StatelessWidget {
|
|
final OtoConsoleSection selected;
|
|
final ValueChanged<OtoConsoleSection> onSelect;
|
|
final OtoConsoleThemeAdapter theme;
|
|
|
|
const _OtoConsoleRail({
|
|
required this.selected,
|
|
required this.onSelect,
|
|
required this.theme,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 60,
|
|
decoration: BoxDecoration(
|
|
color: theme.railBackgroundColor,
|
|
border: Border(right: BorderSide(color: theme.borderColor)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
_RailButton(
|
|
tooltip: 'Overview',
|
|
icon: Icons.dashboard_outlined,
|
|
selected: selected == OtoConsoleSection.overview,
|
|
onPressed: () => onSelect(OtoConsoleSection.overview),
|
|
theme: theme,
|
|
),
|
|
const _RailDivider(),
|
|
_RailButton(
|
|
tooltip: 'Pipelines',
|
|
icon: Icons.account_tree_outlined,
|
|
selected: selected == OtoConsoleSection.pipelines,
|
|
onPressed: () => onSelect(OtoConsoleSection.pipelines),
|
|
theme: theme,
|
|
),
|
|
_RailButton(
|
|
tooltip: 'Executions',
|
|
icon: Icons.play_circle_outline,
|
|
selected: selected == OtoConsoleSection.executions,
|
|
onPressed: () => onSelect(OtoConsoleSection.executions),
|
|
theme: theme,
|
|
),
|
|
const _RailDivider(),
|
|
_RailButton(
|
|
tooltip: 'Runners',
|
|
icon: Icons.precision_manufacturing_outlined,
|
|
selected: selected == OtoConsoleSection.runners,
|
|
onPressed: () => onSelect(OtoConsoleSection.runners),
|
|
theme: theme,
|
|
),
|
|
_RailButton(
|
|
tooltip: 'Artifacts',
|
|
icon: Icons.inventory_2_outlined,
|
|
selected: selected == OtoConsoleSection.artifacts,
|
|
onPressed: () => onSelect(OtoConsoleSection.artifacts),
|
|
theme: theme,
|
|
),
|
|
const Spacer(),
|
|
_RailButton(
|
|
tooltip: 'Settings',
|
|
icon: Icons.tune,
|
|
selected: selected == OtoConsoleSection.settings,
|
|
onPressed: () => onSelect(OtoConsoleSection.settings),
|
|
theme: theme,
|
|
),
|
|
_RailButton(
|
|
tooltip: 'Agent',
|
|
icon: Icons.smart_toy_outlined,
|
|
selected: selected == OtoConsoleSection.agent,
|
|
onPressed: () => onSelect(OtoConsoleSection.agent),
|
|
theme: theme,
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RailButton extends StatelessWidget {
|
|
final String tooltip;
|
|
final IconData icon;
|
|
final bool selected;
|
|
final VoidCallback onPressed;
|
|
final OtoConsoleThemeAdapter theme;
|
|
|
|
const _RailButton({
|
|
required this.tooltip,
|
|
required this.icon,
|
|
required this.selected,
|
|
required this.onPressed,
|
|
required this.theme,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = selected ? theme.primaryColor : theme.textSecondaryColor;
|
|
return Tooltip(
|
|
message: tooltip,
|
|
waitDuration: const Duration(milliseconds: 500),
|
|
child: SizedBox(
|
|
width: 56,
|
|
height: 48,
|
|
child: IconButton(
|
|
onPressed: onPressed,
|
|
icon: Icon(icon, color: color),
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: selected
|
|
? theme.primaryColor.withValues(alpha: 0.1)
|
|
: Colors.transparent,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class OtoConsoleSectionDetail {
|
|
final String label;
|
|
final String value;
|
|
|
|
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;
|
|
final String emptyTitle;
|
|
final String emptyMessage;
|
|
final OtoConsoleThemeAdapter theme;
|
|
final List<OtoConsoleSectionDetail> details;
|
|
final List<OtoConsoleSectionDetailGroup> detailGroups;
|
|
|
|
const OtoConsoleSectionSurface({
|
|
super.key,
|
|
required this.icon,
|
|
required this.title,
|
|
required this.emptyTitle,
|
|
required this.emptyMessage,
|
|
required this.theme,
|
|
this.details = const [],
|
|
this.detailGroups = const [],
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
color: theme.backgroundColor,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 820),
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, color: theme.primaryColor, size: 24),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: theme.textColor,
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
_EmptyStatePanel(
|
|
icon: icon,
|
|
title: emptyTitle,
|
|
message: emptyMessage,
|
|
theme: theme,
|
|
),
|
|
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),
|
|
const SizedBox(height: 10),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyStatePanel extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final String message;
|
|
final OtoConsoleThemeAdapter theme;
|
|
|
|
const _EmptyStatePanel({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.message,
|
|
required this.theme,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return 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(icon, color: theme.primaryColor, size: 32),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
color: theme.textColor,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
message,
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 2,
|
|
style: TextStyle(color: theme.textSecondaryColor),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionDetailTile extends StatelessWidget {
|
|
final OtoConsoleSectionDetail detail;
|
|
final OtoConsoleThemeAdapter theme;
|
|
|
|
const _SectionDetailTile({required this.detail, required this.theme});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: theme.primaryColor.withValues(alpha: 0.06),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: theme.primaryColor.withValues(alpha: 0.18)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 150,
|
|
child: Text(
|
|
detail.label,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: theme.textSecondaryColor,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
detail.value,
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
style: TextStyle(color: theme.textColor),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|