feat: live gRPC pages — drop mock data, talk to the real hub
Studio's three MVP pages now pull live data from a running hub via fai_dart_sdk. Mock fixture removed. - data/hub.dart: HubService singleton wraps fai_dart_sdk's HubClient and exposes UI-friendly types (ModuleSummary, AuditEvent, PendingApproval) so pages don't import protobuf. - pages/modules.dart: FutureBuilder against listModules, groups CapabilityEntry rows by module, retry-on-error UI. - pages/audit.dart: 2s polling Timer, status bar shows "live (polling 2s)" or "disconnected — <error>". - pages/approvals.dart: live listApprovals, Approve sends DecideApproval(APPROVE), Reject opens a reason dialog and sends DecideApproval(REJECT). Both refresh after success. - Connection-error states across all pages: cloud_off icon + hint to start `fai serve` + Retry button. When no hub is running the pages render their disconnected state instead of crashing. When a hub is running, the data is real — no more mock fixture. Bumps fai_studio 0.1.0 -> 0.2.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
7c11598846
commit
127c497f73
6 changed files with 602 additions and 354 deletions
156
lib/data/hub.dart
Normal file
156
lib/data/hub.dart
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
// HubService — singleton wrapper around fai_dart_sdk's HubClient.
|
||||||
|
//
|
||||||
|
// Centralises the gRPC connection so each page just imports
|
||||||
|
// `HubService.instance` instead of constructing its own client.
|
||||||
|
// Methods return UI-friendly types so pages stay free of
|
||||||
|
// protobuf imports.
|
||||||
|
|
||||||
|
import 'package:fai_dart_sdk/fai_dart_sdk.dart';
|
||||||
|
|
||||||
|
class HubService {
|
||||||
|
HubService._();
|
||||||
|
static final HubService instance = HubService._();
|
||||||
|
|
||||||
|
HubClient _client = HubClient();
|
||||||
|
|
||||||
|
/// The endpoint string ("http://host:port") the connection
|
||||||
|
/// targets. Shown in the navigation rail.
|
||||||
|
String get endpointLabel => _client.endpoint.toString();
|
||||||
|
|
||||||
|
/// Reconnect to a new endpoint. Useful if the user changes
|
||||||
|
/// the hub URL at runtime (Phase 1+).
|
||||||
|
Future<void> reconnect(HubEndpoint endpoint) async {
|
||||||
|
await _client.close();
|
||||||
|
_client = HubClient(endpoint: endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> healthy() => _client.healthy();
|
||||||
|
|
||||||
|
Future<List<ModuleSummary>> listModules() async {
|
||||||
|
final caps = await _client.listCapabilities();
|
||||||
|
|
||||||
|
// Group capabilities by module so Studio's UI maps a card
|
||||||
|
// to a module rather than a capability.
|
||||||
|
final byModule = <String, List<CapabilityEntry>>{};
|
||||||
|
for (final c in caps) {
|
||||||
|
byModule.putIfAbsent(c.moduleName, () => []).add(c);
|
||||||
|
}
|
||||||
|
return byModule.entries.map((e) {
|
||||||
|
final caps = e.value;
|
||||||
|
return ModuleSummary(
|
||||||
|
name: e.key,
|
||||||
|
version: caps.first.moduleVersion,
|
||||||
|
capabilities: caps
|
||||||
|
.map((c) => '${c.capability}@${c.version}')
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) => a.name.compareTo(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AuditEvent>> recentEvents({
|
||||||
|
int limit = 50,
|
||||||
|
List<String> types = const [],
|
||||||
|
}) async {
|
||||||
|
final events = await _client.eventLog(limit: limit, types: types);
|
||||||
|
return events
|
||||||
|
.map(
|
||||||
|
(e) => AuditEvent(
|
||||||
|
eventId: e.eventId,
|
||||||
|
timestamp: DateTime.tryParse(e.timestamp) ?? DateTime.now(),
|
||||||
|
type: e.eventType,
|
||||||
|
flowName: e.flowName.isEmpty ? null : e.flowName,
|
||||||
|
stepId: e.stepId.isEmpty ? null : e.stepId,
|
||||||
|
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
|
||||||
|
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
|
||||||
|
error: e.error.isEmpty ? null : e.error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<PendingApproval>> pendingApprovals() async {
|
||||||
|
final entries = await _client.listApprovals(statuses: ['pending']);
|
||||||
|
return entries
|
||||||
|
.map(
|
||||||
|
(e) => PendingApproval(
|
||||||
|
id: e.approvalId,
|
||||||
|
flowName: e.flowName,
|
||||||
|
stepId: e.stepId,
|
||||||
|
prompt: e.prompt,
|
||||||
|
showPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
|
||||||
|
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
|
||||||
|
expiresAt: e.expiresAt.isEmpty
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(e.expiresAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> approve(String id, String reviewer) =>
|
||||||
|
_client.approve(approvalId: id, reviewer: reviewer);
|
||||||
|
|
||||||
|
Future<void> reject(String id, String reviewer, String reason) =>
|
||||||
|
_client.reject(
|
||||||
|
approvalId: id,
|
||||||
|
reviewer: reviewer,
|
||||||
|
reason: reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI-side type, decoupled from the proto wire type so pages
|
||||||
|
/// don't import protobuf packages.
|
||||||
|
class ModuleSummary {
|
||||||
|
final String name;
|
||||||
|
final String version;
|
||||||
|
final List<String> capabilities;
|
||||||
|
|
||||||
|
const ModuleSummary({
|
||||||
|
required this.name,
|
||||||
|
required this.version,
|
||||||
|
required this.capabilities,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuditEvent {
|
||||||
|
final String eventId;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String type;
|
||||||
|
final String? flowName;
|
||||||
|
final String? stepId;
|
||||||
|
final String? moduleName;
|
||||||
|
final int? durationMs;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
const AuditEvent({
|
||||||
|
required this.eventId,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.type,
|
||||||
|
this.flowName,
|
||||||
|
this.stepId,
|
||||||
|
this.moduleName,
|
||||||
|
this.durationMs,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class PendingApproval {
|
||||||
|
final String id;
|
||||||
|
final String flowName;
|
||||||
|
final String stepId;
|
||||||
|
final String prompt;
|
||||||
|
final String? showPreview;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime? expiresAt;
|
||||||
|
|
||||||
|
const PendingApproval({
|
||||||
|
required this.id,
|
||||||
|
required this.flowName,
|
||||||
|
required this.stepId,
|
||||||
|
required this.prompt,
|
||||||
|
this.showPreview,
|
||||||
|
required this.createdAt,
|
||||||
|
this.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
// Mock data for the MVP. Replaced by live gRPC calls via
|
|
||||||
// fai_dart_sdk in the next iteration.
|
|
||||||
|
|
||||||
class ModuleSummary {
|
|
||||||
final String name;
|
|
||||||
final String version;
|
|
||||||
final List<String> capabilities;
|
|
||||||
final List<String> permissions;
|
|
||||||
final List<String> requiresServices;
|
|
||||||
|
|
||||||
const ModuleSummary({
|
|
||||||
required this.name,
|
|
||||||
required this.version,
|
|
||||||
required this.capabilities,
|
|
||||||
required this.permissions,
|
|
||||||
required this.requiresServices,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuditEvent {
|
|
||||||
final String eventId;
|
|
||||||
final DateTime timestamp;
|
|
||||||
final String type;
|
|
||||||
final String? flowName;
|
|
||||||
final String? stepId;
|
|
||||||
final String? moduleName;
|
|
||||||
final int? durationMs;
|
|
||||||
final String? error;
|
|
||||||
|
|
||||||
const AuditEvent({
|
|
||||||
required this.eventId,
|
|
||||||
required this.timestamp,
|
|
||||||
required this.type,
|
|
||||||
this.flowName,
|
|
||||||
this.stepId,
|
|
||||||
this.moduleName,
|
|
||||||
this.durationMs,
|
|
||||||
this.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class PendingApproval {
|
|
||||||
final String id;
|
|
||||||
final String flowName;
|
|
||||||
final String stepId;
|
|
||||||
final String prompt;
|
|
||||||
final String? showPreview;
|
|
||||||
final DateTime createdAt;
|
|
||||||
final DateTime? expiresAt;
|
|
||||||
|
|
||||||
const PendingApproval({
|
|
||||||
required this.id,
|
|
||||||
required this.flowName,
|
|
||||||
required this.stepId,
|
|
||||||
required this.prompt,
|
|
||||||
this.showPreview,
|
|
||||||
required this.createdAt,
|
|
||||||
this.expiresAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockModules = <ModuleSummary>[
|
|
||||||
ModuleSummary(
|
|
||||||
name: 'echo',
|
|
||||||
version: '0.1.0',
|
|
||||||
capabilities: ['debug.echo@0.1.0'],
|
|
||||||
permissions: [],
|
|
||||||
requiresServices: [],
|
|
||||||
),
|
|
||||||
ModuleSummary(
|
|
||||||
name: 'text-extract',
|
|
||||||
version: '0.1.1',
|
|
||||||
capabilities: ['text.extract@0.1.0'],
|
|
||||||
permissions: [],
|
|
||||||
requiresServices: [],
|
|
||||||
),
|
|
||||||
ModuleSummary(
|
|
||||||
name: 'llm-chat',
|
|
||||||
version: '0.1.0',
|
|
||||||
capabilities: ['llm.chat@0.1.0'],
|
|
||||||
permissions: [
|
|
||||||
'net: localhost',
|
|
||||||
'net: 127.0.0.1',
|
|
||||||
'net: api.openai.com',
|
|
||||||
'net: api.anthropic.com',
|
|
||||||
],
|
|
||||||
requiresServices: ['ollama'],
|
|
||||||
),
|
|
||||||
ModuleSummary(
|
|
||||||
name: 'text-translate',
|
|
||||||
version: '0.1.0',
|
|
||||||
capabilities: ['text.translate@0.1.0'],
|
|
||||||
permissions: [
|
|
||||||
'net: localhost',
|
|
||||||
'net: 127.0.0.1',
|
|
||||||
],
|
|
||||||
requiresServices: ['ollama'],
|
|
||||||
),
|
|
||||||
ModuleSummary(
|
|
||||||
name: 'text-summarize',
|
|
||||||
version: '0.1.0',
|
|
||||||
capabilities: ['text.summarize@0.1.0'],
|
|
||||||
permissions: [
|
|
||||||
'net: localhost',
|
|
||||||
'net: 127.0.0.1',
|
|
||||||
],
|
|
||||||
requiresServices: ['ollama'],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final mockEvents = <AuditEvent>[
|
|
||||||
AuditEvent(
|
|
||||||
eventId: '01926f8a-7c34-7b2c-9e4f-a8d3c1f9e2b4',
|
|
||||||
timestamp: DateTime.now().subtract(const Duration(seconds: 2)),
|
|
||||||
type: 'flow.completed',
|
|
||||||
flowName: 'extract-translate-summarize',
|
|
||||||
durationMs: 8421,
|
|
||||||
),
|
|
||||||
AuditEvent(
|
|
||||||
eventId: '01926f8a-7b00-7000-aaaa-bbbbcccc0042',
|
|
||||||
timestamp: DateTime.now().subtract(const Duration(seconds: 3)),
|
|
||||||
type: 'step.completed',
|
|
||||||
flowName: 'extract-translate-summarize',
|
|
||||||
stepId: 'summarize',
|
|
||||||
moduleName: 'text-summarize',
|
|
||||||
durationMs: 3204,
|
|
||||||
),
|
|
||||||
AuditEvent(
|
|
||||||
eventId: '01926f8a-7a90-7000-aaaa-bbbbcccc0041',
|
|
||||||
timestamp: DateTime.now().subtract(const Duration(seconds: 9)),
|
|
||||||
type: 'step.completed',
|
|
||||||
flowName: 'extract-translate-summarize',
|
|
||||||
stepId: 'review',
|
|
||||||
moduleName: 'system.approval',
|
|
||||||
durationMs: 5102,
|
|
||||||
),
|
|
||||||
AuditEvent(
|
|
||||||
eventId: '01926f8a-7a00-7000-aaaa-bbbbcccc0040',
|
|
||||||
timestamp: DateTime.now().subtract(const Duration(seconds: 14)),
|
|
||||||
type: 'step.completed',
|
|
||||||
flowName: 'extract-translate-summarize',
|
|
||||||
stepId: 'translate',
|
|
||||||
moduleName: 'text-translate',
|
|
||||||
durationMs: 4815,
|
|
||||||
),
|
|
||||||
AuditEvent(
|
|
||||||
eventId: '01926f8a-7800-7000-aaaa-bbbbcccc003f',
|
|
||||||
timestamp: DateTime.now().subtract(const Duration(seconds: 19)),
|
|
||||||
type: 'flow.started',
|
|
||||||
flowName: 'extract-translate-summarize',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final mockApprovals = <PendingApproval>[
|
|
||||||
PendingApproval(
|
|
||||||
id: '01926f8a-9000-7b2c-9e4f-aaaa00000001',
|
|
||||||
flowName: 'extract-translate-summarize',
|
|
||||||
stepId: 'review',
|
|
||||||
prompt: 'Review the German→English translation before summarisation.',
|
|
||||||
showPreview:
|
|
||||||
'{ "translated": "The quarterly report shows..." }',
|
|
||||||
createdAt: DateTime.now().subtract(const Duration(minutes: 2)),
|
|
||||||
expiresAt: DateTime.now().add(const Duration(minutes: 28)),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/mock.dart';
|
import '../data/hub.dart';
|
||||||
|
|
||||||
class ApprovalsPage extends StatefulWidget {
|
class ApprovalsPage extends StatefulWidget {
|
||||||
const ApprovalsPage({super.key});
|
const ApprovalsPage({super.key});
|
||||||
|
|
@ -10,20 +10,121 @@ class ApprovalsPage extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ApprovalsPageState extends State<ApprovalsPage> {
|
class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
final _decided = <String>{};
|
late Future<List<PendingApproval>> _future;
|
||||||
|
// Hard-coded reviewer for the MVP; Phase 1+ wires this to an
|
||||||
|
// authenticated session.
|
||||||
|
final String _reviewer = 'studio-mvp';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = HubService.instance.pendingApprovals();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refresh() => setState(() {
|
||||||
|
_future = HubService.instance.pendingApprovals();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _approve(PendingApproval a) async {
|
||||||
|
try {
|
||||||
|
await HubService.instance.approve(a.id, _reviewer);
|
||||||
|
_refresh();
|
||||||
|
} catch (e) {
|
||||||
|
_showError('approve failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reject(PendingApproval a) async {
|
||||||
|
final reason = await _promptReason(context);
|
||||||
|
if (reason == null || reason.isEmpty) return;
|
||||||
|
try {
|
||||||
|
await HubService.instance.reject(a.id, _reviewer, reason);
|
||||||
|
_refresh();
|
||||||
|
} catch (e) {
|
||||||
|
_showError('reject failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showError(String msg) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _promptReason(BuildContext context) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Reject approval'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Reason (recorded in audit log)',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, null),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||||
|
child: const Text('Reject'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final pending =
|
|
||||||
mockApprovals.where((a) => !_decided.contains(a.id)).toList();
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Pending Approvals'),
|
title: const Text('Pending Approvals'),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Reload',
|
||||||
|
onPressed: _refresh,
|
||||||
),
|
),
|
||||||
body: pending.isEmpty
|
],
|
||||||
? Center(
|
),
|
||||||
|
body: FutureBuilder<List<PendingApproval>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_off_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Hub unreachable: ${snapshot.error}'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: _refresh,
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final pending = snapshot.data ?? [];
|
||||||
|
if (pending.isEmpty) {
|
||||||
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -32,7 +133,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.check_circle_outline,
|
Icons.check_circle_outline,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.5),
|
color: theme.colorScheme.primary
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -44,8 +146,9 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
: ListView.separated(
|
}
|
||||||
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
itemCount: pending.length,
|
itemCount: pending.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
||||||
|
|
@ -53,10 +156,10 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
final a = pending[i];
|
final a = pending[i];
|
||||||
return _ApprovalCard(
|
return _ApprovalCard(
|
||||||
approval: a,
|
approval: a,
|
||||||
onApprove: () =>
|
onApprove: () => _approve(a),
|
||||||
setState(() => _decided.add(a.id)),
|
onReject: () => _reject(a),
|
||||||
onReject: () =>
|
);
|
||||||
setState(() => _decided.add(a.id)),
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/mock.dart';
|
import '../data/hub.dart';
|
||||||
|
|
||||||
class AuditPage extends StatefulWidget {
|
class AuditPage extends StatefulWidget {
|
||||||
const AuditPage({super.key});
|
const AuditPage({super.key});
|
||||||
|
|
@ -11,13 +13,57 @@ class AuditPage extends StatefulWidget {
|
||||||
|
|
||||||
class _AuditPageState extends State<AuditPage> {
|
class _AuditPageState extends State<AuditPage> {
|
||||||
String _typeFilter = 'all';
|
String _typeFilter = 'all';
|
||||||
|
List<AuditEvent> _events = const [];
|
||||||
|
String? _error;
|
||||||
|
bool _initialLoaded = false;
|
||||||
|
Timer? _poller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_refresh();
|
||||||
|
_poller = Timer.periodic(
|
||||||
|
const Duration(seconds: 2),
|
||||||
|
(_) => _refresh(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_poller?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
final types = _typeFilter == 'all'
|
||||||
|
? <String>[]
|
||||||
|
: <String>[]; // server-side filter not yet — clientside below
|
||||||
|
try {
|
||||||
|
final events = await HubService.instance.recentEvents(
|
||||||
|
limit: 100,
|
||||||
|
types: types,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_events = events;
|
||||||
|
_error = null;
|
||||||
|
_initialLoaded = true;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_initialLoaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final filtered = _typeFilter == 'all'
|
final filtered = _typeFilter == 'all'
|
||||||
? mockEvents
|
? _events
|
||||||
: mockEvents.where((e) => e.type.startsWith(_typeFilter)).toList();
|
: _events.where((e) => e.type.startsWith(_typeFilter)).toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -41,18 +87,41 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
_StatusBar(eventCount: filtered.length),
|
_StatusBar(eventCount: filtered.length, error: _error),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.separated(
|
child: !_initialLoaded
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null && _events.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Text(
|
||||||
|
'Hub unreachable: $_error\n\n'
|
||||||
|
'Start the hub with `fai serve`.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: filtered.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'(no events yet)',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
itemCount: filtered.length,
|
itemCount: filtered.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
separatorBuilder: (_, _) =>
|
||||||
|
const SizedBox(height: 8),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final e = filtered[i];
|
final e = filtered[i];
|
||||||
final ts = e.timestamp;
|
final ts = e.timestamp;
|
||||||
final tsStr =
|
final tsStr =
|
||||||
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
|
'${ts.hour.toString().padLeft(2, '0')}:${ts.minute.toString().padLeft(2, '0')}:${ts.second.toString().padLeft(2, '0')}';
|
||||||
final tone = _toneFor(e.type);
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|
@ -72,7 +141,7 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
width: 4,
|
width: 4,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: tone,
|
color: _toneFor(e.type, theme),
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -81,9 +150,11 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
width: 90,
|
width: 90,
|
||||||
child: Text(
|
child: Text(
|
||||||
tsStr,
|
tsStr,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style:
|
||||||
|
theme.textTheme.bodySmall?.copyWith(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme
|
||||||
|
.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -91,7 +162,8 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
width: 180,
|
width: 180,
|
||||||
child: Text(
|
child: Text(
|
||||||
e.type,
|
e.type,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style:
|
||||||
|
theme.textTheme.bodyMedium?.copyWith(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
|
|
@ -100,17 +172,21 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_contextLine(e),
|
_contextLine(e),
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style:
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme
|
||||||
|
.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (e.durationMs != null)
|
if (e.durationMs != null)
|
||||||
Text(
|
Text(
|
||||||
'${e.durationMs}ms',
|
'${e.durationMs}ms',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style:
|
||||||
|
theme.textTheme.bodySmall?.copyWith(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme
|
||||||
|
.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -134,23 +210,24 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
return parts.join('');
|
return parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _toneFor(String type) {
|
Color _toneFor(String type, ThemeData theme) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
if (type.endsWith('.failed')) return theme.colorScheme.error;
|
||||||
if (type.endsWith('.failed')) return scheme.error;
|
if (type.endsWith('.completed')) return theme.colorScheme.primary;
|
||||||
if (type.endsWith('.completed')) return scheme.primary;
|
if (type.endsWith('.started')) return theme.colorScheme.tertiary;
|
||||||
if (type.endsWith('.started')) return scheme.tertiary;
|
return theme.colorScheme.outline;
|
||||||
return scheme.outline;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatusBar extends StatelessWidget {
|
class _StatusBar extends StatelessWidget {
|
||||||
final int eventCount;
|
final int eventCount;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
const _StatusBar({required this.eventCount});
|
const _StatusBar({required this.eventCount, required this.error});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final live = error == null;
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
|
@ -160,20 +237,15 @@ class _StatusBar extends StatelessWidget {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.fiber_manual_record,
|
Icons.fiber_manual_record,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: theme.colorScheme.tertiary,
|
color: live ? theme.colorScheme.tertiary : theme.colorScheme.error,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'live (mock data) — $eventCount events',
|
live
|
||||||
|
? 'live (polling 2s) — $eventCount events'
|
||||||
|
: 'disconnected — $error',
|
||||||
style: theme.textTheme.bodySmall,
|
style: theme.textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
'hash chain: verified',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,27 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/mock.dart';
|
import '../data/hub.dart';
|
||||||
|
|
||||||
class ModulesPage extends StatelessWidget {
|
class ModulesPage extends StatefulWidget {
|
||||||
const ModulesPage({super.key});
|
const ModulesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ModulesPage> createState() => _ModulesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModulesPageState extends State<ModulesPage> {
|
||||||
|
late Future<List<ModuleSummary>> _future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = HubService.instance.listModules();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refresh() => setState(() {
|
||||||
|
_future = HubService.instance.listModules();
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -12,13 +29,48 @@ class ModulesPage extends StatelessWidget {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Installed Modules'),
|
title: const Text('Installed Modules'),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Reload',
|
||||||
|
onPressed: _refresh,
|
||||||
),
|
),
|
||||||
body: ListView.separated(
|
],
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<ModuleSummary>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return _ConnectionError(
|
||||||
|
message: 'Hub unreachable: ${snapshot.error}',
|
||||||
|
onRetry: _refresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final modules = snapshot.data ?? [];
|
||||||
|
if (modules.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Text(
|
||||||
|
'No modules installed.\n'
|
||||||
|
'Run `fai install <capability-name>` or check ~/.fai/modules/.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
itemCount: mockModules.length,
|
itemCount: modules.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final m = mockModules[i];
|
final m = modules[i];
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|
@ -58,33 +110,24 @@ class ModulesPage extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_Row(label: 'Capabilities', items: m.capabilities),
|
_ChipRow(label: 'Capabilities', items: m.capabilities),
|
||||||
if (m.permissions.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_Row(label: 'Permissions', items: m.permissions),
|
|
||||||
],
|
|
||||||
if (m.requiresServices.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_Row(
|
|
||||||
label: 'Requires services',
|
|
||||||
items: m.requiresServices,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Row extends StatelessWidget {
|
class _ChipRow extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final List<String> items;
|
final List<String> items;
|
||||||
|
|
||||||
const _Row({required this.label, required this.items});
|
const _ChipRow({required this.label, required this.items});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -131,3 +174,48 @@ class _Row extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ConnectionError extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
const _ConnectionError({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_off_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Start the hub with `fai serve`.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
// Smoke test: app boots, navigation rail shows the three MVP
|
// Smoke test: app boots without crashing and shows the three
|
||||||
// pages, and the Modules page renders the mocked module list.
|
// MVP destinations in the navigation rail.
|
||||||
|
//
|
||||||
|
// Pages themselves now make live gRPC calls. We pump only one
|
||||||
|
// frame so they enter the loading state without actually
|
||||||
|
// awaiting the connection (which would fail without a running
|
||||||
|
// hub in CI).
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:fai_studio/main.dart';
|
import 'package:fai_studio/main.dart';
|
||||||
|
|
@ -8,21 +13,10 @@ void main() {
|
||||||
testWidgets('Studio shell shows the three MVP destinations',
|
testWidgets('Studio shell shows the three MVP destinations',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(const StudioApp());
|
await tester.pumpWidget(const StudioApp());
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(find.text('F∆I Studio'), findsOneWidget);
|
expect(find.text('F∆I Studio'), findsOneWidget);
|
||||||
expect(find.text('Modules'), findsWidgets);
|
expect(find.text('Modules'), findsWidgets);
|
||||||
expect(find.text('Audit'), findsOneWidget);
|
expect(find.text('Audit'), findsOneWidget);
|
||||||
expect(find.text('Approvals'), findsOneWidget);
|
expect(find.text('Approvals'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Modules page lists the mocked modules',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(const StudioApp());
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(find.text('echo'), findsOneWidget);
|
|
||||||
expect(find.text('text-extract'), findsOneWidget);
|
|
||||||
expect(find.text('llm-chat'), findsOneWidget);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue