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)
|
## Embedding
|
||||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
|
||||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
Add the package from another workspace Flutter app:
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
```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';
|
import 'src/app/bootstrap.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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
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:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:oto_client/main.dart';
|
import 'package:oto_client/src/app/oto_client_app.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('OTO Console app shell rendering test', (WidgetTester tester) async {
|
testWidgets('OTO client hosts embeddable console widgets', (tester) async {
|
||||||
// Build our app and trigger a frame.
|
await tester.pumpWidget(const OtoClientApp());
|
||||||
await tester.pumpWidget(const MyApp());
|
|
||||||
|
|
||||||
// Verify that our app bar shows the correct title.
|
expect(find.text('Build Operations Overview'), findsOneWidget);
|
||||||
expect(find.text('OTO Console'), findsOneWidget);
|
expect(find.byTooltip('Agent'), findsOneWidget);
|
||||||
|
|
||||||
// Verify that welcome text and subtext are rendered.
|
await tester.tap(find.byTooltip('Agent'));
|
||||||
expect(find.text('Welcome to OTO Console'), findsOneWidget);
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Independent Control Plane'), findsOneWidget);
|
|
||||||
|
|
||||||
// Verify dashboard icon is present.
|
expect(find.textContaining('OTO agent surface is ready'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.dashboard), 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