feat(client): 모듈형 OTO 콘솔 패키지를 추가한다
iop와 nomadcode가 같은 workspace에서 제품별 Flutter 콘솔을 위젯으로 삽입할 수 있어야 하므로 OTO client를 얇은 host 앱으로 정리하고 agent-shell 기반 embeddable console 패키지를 분리한다.
This commit is contained in:
parent
1201f59e2a
commit
fac57a96fd
15 changed files with 1002 additions and 86 deletions
|
|
@ -1,17 +1,30 @@
|
|||
# oto_client
|
||||
# OTO Client
|
||||
|
||||
A new Flutter project.
|
||||
Flutter host application for the OTO console.
|
||||
|
||||
## Getting Started
|
||||
The embeddable UI surface lives in `packages/flutter/oto_console` so other
|
||||
workspace apps, such as `../nomadcode/apps/client`, can mount OTO as a modular
|
||||
Flutter widget instead of depending on this host application's `main.dart`.
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
## Structure
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
- `lib/main.dart` only starts the OTO client host.
|
||||
- `lib/src/app/bootstrap.dart` owns app startup concerns such as fullscreen
|
||||
system UI setup.
|
||||
- `lib/src/app/oto_client_app.dart` wires the host `MaterialApp` to
|
||||
`OtoConsoleShell`.
|
||||
- `../../packages/flutter/oto_console` exports the product-owned embeddable
|
||||
widgets. That package consumes the workspace shared `agent-shell` package.
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
## Embedding
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
Add the package from another workspace Flutter app:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
oto_console:
|
||||
path: ../../../oto/packages/flutter/oto_console
|
||||
```
|
||||
|
||||
Then mount any exported widget, for example `OtoConsoleShell` or
|
||||
`OtoAgentPanel`, inside the host application's own workbench shell.
|
||||
|
|
|
|||
|
|
@ -1,56 +1,3 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'OTO Console',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(title: 'OTO Console'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatelessWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
title: Text(title),
|
||||
),
|
||||
body: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.dashboard, size: 64, color: Colors.blueGrey),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Welcome to OTO Console',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Independent Control Plane',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'src/app/bootstrap.dart';
|
||||
|
||||
Future<void> main() => runOtoClient();
|
||||
|
|
|
|||
25
apps/client/lib/src/app/bootstrap.dart
Normal file
25
apps/client/lib/src/app/bootstrap.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'oto_client_app.dart';
|
||||
|
||||
Future<void> applyFullscreenMode() async {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> bootstrapOtoClient() async {
|
||||
await applyFullscreenMode();
|
||||
}
|
||||
|
||||
Future<void> runOtoClient() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await bootstrapOtoClient();
|
||||
runApp(const OtoClientApp());
|
||||
}
|
||||
77
apps/client/lib/src/app/oto_client_app.dart
Normal file
77
apps/client/lib/src/app/oto_client_app.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:oto_console/oto_console.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 StatelessWidget {
|
||||
final OtoConsoleConfig config;
|
||||
final String statusText;
|
||||
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.statusText = 'Disconnected',
|
||||
this.capabilities = otoDefaultCapabilityPack,
|
||||
});
|
||||
|
||||
@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: config,
|
||||
capabilities: capabilities,
|
||||
themeAdapter: themeAdapter,
|
||||
overview: OtoConsoleOverview(
|
||||
config: config,
|
||||
statusText: statusText,
|
||||
themeAdapter: themeAdapter,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,14 @@ dependencies:
|
|||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# Shared embeddable agent interaction shell.
|
||||
agent_shell:
|
||||
path: ../../../agent-shell
|
||||
|
||||
# OTO-owned embeddable console widgets.
|
||||
oto_console:
|
||||
path: ../../packages/flutter/oto_console
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
|
|||
|
|
@ -1,29 +1,18 @@
|
|||
// This is a basic Flutter widget test for OTO Console app shell.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:oto_client/main.dart';
|
||||
import 'package:oto_client/src/app/oto_client_app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('OTO Console app shell rendering test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
testWidgets('OTO client hosts embeddable console widgets', (tester) async {
|
||||
await tester.pumpWidget(const OtoClientApp());
|
||||
|
||||
// Verify that our app bar shows the correct title.
|
||||
expect(find.text('OTO Console'), findsOneWidget);
|
||||
expect(find.text('Build Operations Overview'), findsOneWidget);
|
||||
expect(find.byTooltip('Agent'), findsOneWidget);
|
||||
|
||||
// Verify that welcome text and subtext are rendered.
|
||||
expect(find.text('Welcome to OTO Console'), findsOneWidget);
|
||||
expect(find.text('Independent Control Plane'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Agent'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify dashboard icon is present.
|
||||
expect(find.byIcon(Icons.dashboard), findsOneWidget);
|
||||
expect(find.textContaining('OTO agent surface is ready'), findsOneWidget);
|
||||
expect(find.textContaining('Runner Registry'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
12
packages/flutter/oto_console/.gitignore
vendored
Normal file
12
packages/flutter/oto_console/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Flutter/Dart generated files
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Local IDE files
|
||||
.idea/
|
||||
*.iml
|
||||
6
packages/flutter/oto_console/analysis_options.yaml
Normal file
6
packages/flutter/oto_console/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
prefer_final_fields: true
|
||||
4
packages/flutter/oto_console/lib/oto_console.dart
Normal file
4
packages/flutter/oto_console/lib/oto_console.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export 'src/oto_agent_panel.dart';
|
||||
export 'src/oto_console_contract.dart';
|
||||
export 'src/oto_console_overview.dart';
|
||||
export 'src/oto_console_shell.dart';
|
||||
42
packages/flutter/oto_console/lib/src/oto_agent_panel.dart
Normal file
42
packages/flutter/oto_console/lib/src/oto_agent_panel.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import 'package:agent_shell/agent_shell.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'oto_console_contract.dart';
|
||||
|
||||
class OtoAgentPanel extends StatelessWidget {
|
||||
final ValueChanged<String>? onSubmit;
|
||||
final OtoCapabilityPack? capabilities;
|
||||
|
||||
const OtoAgentPanel({super.key, this.onSubmit, this.capabilities});
|
||||
|
||||
List<AgentMessage> _buildMessages() {
|
||||
final introText = StringBuffer(
|
||||
'OTO agent surface is ready. Runner, pipeline, execution, log, and artifact capabilities can be registered here.',
|
||||
);
|
||||
|
||||
if (capabilities != null && capabilities!.capabilities.isNotEmpty) {
|
||||
introText.write('\n\nRegistered capabilities:');
|
||||
for (final capability in capabilities!.capabilities) {
|
||||
introText.write('\n- ${capability.name}: ${capability.description}');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
AgentMessage(
|
||||
id: 'oto-agent-intro',
|
||||
role: AgentMessageRole.assistant,
|
||||
text: introText.toString(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AgentShell(
|
||||
messages: _buildMessages(),
|
||||
busy: false,
|
||||
placeholder: 'Ask about OTO builds',
|
||||
onSubmit: onSubmit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'oto_console_shell.dart';
|
||||
|
||||
class OtoConsoleConfig {
|
||||
final String serverHttpUrl;
|
||||
final String serverWireUrl;
|
||||
final String? authTokenReference;
|
||||
|
||||
const OtoConsoleConfig({
|
||||
required this.serverHttpUrl,
|
||||
required this.serverWireUrl,
|
||||
this.authTokenReference,
|
||||
});
|
||||
}
|
||||
|
||||
class OtoConsoleThemeAdapter {
|
||||
final Color primaryColor;
|
||||
final Color accentColor;
|
||||
final Color backgroundColor;
|
||||
final Color railBackgroundColor;
|
||||
final Color borderColor;
|
||||
final Color textColor;
|
||||
final Color textSecondaryColor;
|
||||
|
||||
const OtoConsoleThemeAdapter({
|
||||
this.primaryColor = const Color(0xFF22D3EE),
|
||||
this.accentColor = const Color(0xFFF59E0B),
|
||||
this.backgroundColor = const Color(0xFF111318),
|
||||
this.railBackgroundColor = const Color(0xFF181B22),
|
||||
this.borderColor = const Color(0xFF2A2F3A),
|
||||
this.textColor = Colors.white,
|
||||
this.textSecondaryColor = Colors.white60,
|
||||
});
|
||||
}
|
||||
|
||||
typedef OtoConsoleNavigationCallback = void Function(OtoConsoleSection section);
|
||||
|
||||
class OtoConsoleNavigation {
|
||||
final OtoConsoleNavigationCallback? onNavigate;
|
||||
|
||||
const OtoConsoleNavigation({this.onNavigate});
|
||||
}
|
||||
|
||||
class OtoCapability {
|
||||
final String name;
|
||||
final String description;
|
||||
final IconData? icon;
|
||||
|
||||
const OtoCapability({
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
class OtoCapabilityPack {
|
||||
final List<OtoCapability> capabilities;
|
||||
|
||||
const OtoCapabilityPack({required this.capabilities});
|
||||
}
|
||||
362
packages/flutter/oto_console/lib/src/oto_console_overview.dart
Normal file
362
packages/flutter/oto_console/lib/src/oto_console_overview.dart
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'oto_console_contract.dart';
|
||||
|
||||
class OtoConsoleOverview extends StatelessWidget {
|
||||
final OtoConsoleConfig config;
|
||||
final String statusText;
|
||||
final VoidCallback? onRefresh;
|
||||
final OtoConsoleThemeAdapter? themeAdapter;
|
||||
|
||||
const OtoConsoleOverview({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.statusText,
|
||||
this.onRefresh,
|
||||
this.themeAdapter,
|
||||
});
|
||||
|
||||
bool get _connected => statusText.trim().toLowerCase() == 'connected';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = themeAdapter ?? const OtoConsoleThemeAdapter();
|
||||
final statusColor = _connected ? theme.primaryColor : theme.accentColor;
|
||||
|
||||
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: statusText,
|
||||
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: 'SERVER 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,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_EndpointTile(
|
||||
title: 'OTO Server Wire',
|
||||
value: config.serverWireUrl,
|
||||
icon: Icons.swap_calls_outlined,
|
||||
theme: theme,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_SectionHeader(
|
||||
title: 'RUNNER EXECUTION SURFACE',
|
||||
icon: Icons.terminal_outlined,
|
||||
theme: theme,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_MetricGrid(theme: theme, connected: _connected),
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
const _StatusBadge({required this.label, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label.toUpperCase(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EndpointTile extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
|
||||
const _EndpointTile({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
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: Row(
|
||||
children: [
|
||||
Icon(icon, color: theme.primaryColor, size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricGrid extends StatelessWidget {
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
final bool connected;
|
||||
|
||||
const _MetricGrid({required this.theme, required this.connected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final narrow = constraints.maxWidth < 620;
|
||||
final tiles = [
|
||||
_MetricTile(
|
||||
title: 'Status',
|
||||
value: connected ? 'ACTIVE WIRE' : 'OFFLINE',
|
||||
color: connected ? theme.primaryColor : theme.textSecondaryColor,
|
||||
theme: theme,
|
||||
),
|
||||
_MetricTile(
|
||||
title: 'Domain',
|
||||
value: 'Build / Deploy',
|
||||
color: theme.accentColor,
|
||||
theme: theme,
|
||||
),
|
||||
];
|
||||
if (narrow) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [tiles[0], const SizedBox(height: 16), tiles[1]],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: tiles[0]),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: tiles[1]),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricTile extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final Color color;
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
|
||||
const _MetricTile({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.color,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
value,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
307
packages/flutter/oto_console/lib/src/oto_console_shell.dart
Normal file
307
packages/flutter/oto_console/lib/src/oto_console_shell.dart
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'oto_agent_panel.dart';
|
||||
import 'oto_console_contract.dart';
|
||||
|
||||
enum OtoConsoleSection {
|
||||
overview,
|
||||
runners,
|
||||
pipelines,
|
||||
executions,
|
||||
artifacts,
|
||||
agent,
|
||||
settings,
|
||||
}
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter();
|
||||
return Scaffold(
|
||||
backgroundColor: theme.backgroundColor,
|
||||
body: SafeArea(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_OtoConsoleRail(
|
||||
selected: _section,
|
||||
onSelect: _select,
|
||||
theme: theme,
|
||||
),
|
||||
Expanded(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 ??
|
||||
_OtoPlaceholder(
|
||||
icon: Icons.precision_manufacturing_outlined,
|
||||
title: 'Runners',
|
||||
subtitle:
|
||||
'Runner registry, heartbeat, and bootstrap controls mount here.',
|
||||
theme: theme,
|
||||
),
|
||||
OtoConsoleSection.pipelines =>
|
||||
widget.pipelines ??
|
||||
_OtoPlaceholder(
|
||||
icon: Icons.account_tree_outlined,
|
||||
title: 'Pipelines',
|
||||
subtitle:
|
||||
'Typed command pipelines and build profiles mount here.',
|
||||
theme: theme,
|
||||
),
|
||||
OtoConsoleSection.executions =>
|
||||
widget.executions ??
|
||||
_OtoPlaceholder(
|
||||
icon: Icons.play_circle_outline,
|
||||
title: 'Executions',
|
||||
subtitle:
|
||||
'Remote run status, cancellation, and execution history mount here.',
|
||||
theme: theme,
|
||||
),
|
||||
OtoConsoleSection.artifacts =>
|
||||
widget.artifacts ??
|
||||
_OtoPlaceholder(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
title: 'Artifacts',
|
||||
subtitle:
|
||||
'Build outputs, artifact events, and retention views mount here.',
|
||||
theme: theme,
|
||||
),
|
||||
OtoConsoleSection.agent =>
|
||||
widget.agent ?? OtoAgentPanel(capabilities: widget.capabilities),
|
||||
OtoConsoleSection.settings =>
|
||||
widget.settings ??
|
||||
_OtoPlaceholder(
|
||||
icon: Icons.tune,
|
||||
title: 'Settings',
|
||||
subtitle:
|
||||
'Server endpoints, credentials, and operator preferences mount here.',
|
||||
theme: theme,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
_RailButton(
|
||||
tooltip: 'Runners',
|
||||
icon: Icons.precision_manufacturing_outlined,
|
||||
selected: selected == OtoConsoleSection.runners,
|
||||
onPressed: () => onSelect(OtoConsoleSection.runners),
|
||||
theme: theme,
|
||||
),
|
||||
_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,
|
||||
),
|
||||
_RailButton(
|
||||
tooltip: 'Artifacts',
|
||||
icon: Icons.inventory_2_outlined,
|
||||
selected: selected == OtoConsoleSection.artifacts,
|
||||
onPressed: () => onSelect(OtoConsoleSection.artifacts),
|
||||
theme: theme,
|
||||
),
|
||||
const Spacer(),
|
||||
_RailButton(
|
||||
tooltip: 'Agent',
|
||||
icon: Icons.smart_toy_outlined,
|
||||
selected: selected == OtoConsoleSection.agent,
|
||||
onPressed: () => onSelect(OtoConsoleSection.agent),
|
||||
theme: theme,
|
||||
),
|
||||
_RailButton(
|
||||
tooltip: 'Settings',
|
||||
icon: Icons.tune,
|
||||
selected: selected == OtoConsoleSection.settings,
|
||||
onPressed: () => onSelect(OtoConsoleSection.settings),
|
||||
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 _OtoPlaceholder extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
|
||||
const _OtoPlaceholder({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: theme.backgroundColor,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: theme.primaryColor, size: 40),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
packages/flutter/oto_console/pubspec.yaml
Normal file
22
packages/flutter/oto_console/pubspec.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
name: oto_console
|
||||
description: "Embeddable Flutter console widgets for OTO build and deploy operations."
|
||||
version: 0.1.0
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.3
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
agent_shell:
|
||||
path: ../../../../agent-shell
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
41
packages/flutter/oto_console/test/oto_console_test.dart
Normal file
41
packages/flutter/oto_console/test/oto_console_test.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:oto_console/oto_console.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('renders embeddable OTO console shell', (tester) async {
|
||||
const config = OtoConsoleConfig(
|
||||
serverHttpUrl: 'http://localhost:8080',
|
||||
serverWireUrl: 'ws://localhost:18080/runner',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: OtoConsoleShell(
|
||||
config: config,
|
||||
capabilities: OtoCapabilityPack(
|
||||
capabilities: [
|
||||
OtoCapability(
|
||||
name: 'Remote Run',
|
||||
description: 'Dispatch remote build runs.',
|
||||
),
|
||||
],
|
||||
),
|
||||
overview: OtoConsoleOverview(
|
||||
config: config,
|
||||
statusText: 'Disconnected',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Build Operations Overview'), findsOneWidget);
|
||||
expect(find.byTooltip('Agent'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Agent'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('OTO agent surface is ready'), findsOneWidget);
|
||||
expect(find.textContaining('Remote Run'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue