reclaim/app/lib/pages/hub_status_page.dart
flemming-it cf0ee4fd7a feat(hub): list installed capabilities from the connected hub
HubCapabilitiesCard wires HubClient.listCapabilities() into the
hub-status page below the connection form: once the app starts
in hub mode and the connection is healthy, the card shows
capability@version, the providing module, and its kind for
every entry the hub reports.

In mock mode the card prints an explanatory hint instead of
loading, so no confusing empty-state appears when the user is
just browsing the demo data.

A small refresh-icon next to the heading lets the user re-poll
without a full page reload — useful right after a chain install
in another terminal.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-18 11:47:28 +02:00

296 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import '../data/hub_endpoint.dart';
import '../data/hub_repository.dart';
import '../data/repository.dart';
import '../theme/lawheatmap_tokens.dart';
import '../widgets/hub_capabilities_card.dart';
import '../widgets/lawheatmap_card.dart';
/// Hub connection + settings. Read-only summary at the top, then
/// an editable form for endpoint host/port/secure/token, then a
/// "Verbinden / Test"-button. Changes are persisted via
/// HubSettings and take effect on the next app start (Phase 0
/// keeps the running repository to avoid mid-session swap
/// headaches).
class HubStatusPage extends StatefulWidget {
const HubStatusPage({super.key, required this.repository});
final EvaluationRepository repository;
@override
State<HubStatusPage> createState() => _HubStatusPageState();
}
class _HubStatusPageState extends State<HubStatusPage> {
HubSettings? _settings;
late final TextEditingController _hostCtl;
late final TextEditingController _portCtl;
late final TextEditingController _tokenCtl;
bool _secure = false;
bool _useHub = false;
String? _probeMessage;
bool _probing = false;
@override
void initState() {
super.initState();
_hostCtl = TextEditingController();
_portCtl = TextEditingController();
_tokenCtl = TextEditingController();
HubSettings.load().then((s) {
setState(() {
_settings = s;
_hostCtl.text = s.host;
_portCtl.text = s.port.toString();
_tokenCtl.text = s.authToken ?? '';
_secure = s.secure;
_useHub = s.useHub;
});
});
}
@override
void dispose() {
_hostCtl.dispose();
_portCtl.dispose();
_tokenCtl.dispose();
super.dispose();
}
HubSettings _formSettings() => HubSettings(
host: _hostCtl.text.trim().isEmpty
? HubSettings.defaultHost
: _hostCtl.text.trim(),
port: int.tryParse(_portCtl.text.trim()) ??
HubSettings.defaultPort,
secure: _secure,
authToken:
_tokenCtl.text.trim().isEmpty ? null : _tokenCtl.text.trim(),
useHub: _useHub,
);
Future<void> _save() async {
final s = _formSettings();
await s.save();
if (!mounted) return;
setState(() => _settings = s);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Einstellungen gespeichert. '
'Beim nächsten Start wird die App den Hub verwenden, '
'wenn der Schalter „Hub nutzen" aktiv ist.'),
),
);
}
Future<void> _probe() async {
setState(() {
_probing = true;
_probeMessage = null;
});
try {
final hub = await HubRepository.connect(_formSettings());
setState(() {
_probeMessage = hub.isHealthy
? 'Verbindung erfolgreich (healthy=true).'
: 'Hub nicht erreichbar oder antwortet nicht SERVING.';
});
} catch (e) {
setState(() => _probeMessage = 'Fehler: $e');
} finally {
if (mounted) setState(() => _probing = false);
}
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final repo = widget.repository;
final s = _settings;
return SingleChildScrollView(
padding: const EdgeInsets.all(LawHeatmapSpace.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ch∆In Hub', style: t.displaySmall),
const SizedBox(height: LawHeatmapSpace.xs),
Text(
'Status der Verbindung und Konfiguration des lokalen Hubs.',
style: t.bodyLarge?.copyWith(color: LawHeatmapColors.mute),
),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
accent: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_row('Aktiver Modus', repo.mode.toUpperCase(), t),
_row('Datenquelle', repo.hubLabel, t),
_row(
'gRPC-Endpunkt',
s == null ? '' : s.url,
t,
),
_row(
'Hub-Nutzung beim Start',
s == null ? '' : (s.useHub ? 'ja' : 'nein'),
t,
),
_row('Audit-DB', '~/.chain/chain.db (Hub-seitig)', t),
_row('Hash-Tag', 'CHAIN-EVENT-V3', t),
],
),
),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Verbindung konfigurieren',
style: t.headlineSmall),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'`chain serve` läuft standardmäßig auf '
'127.0.0.1:50051 ohne Auth. '
'Bei aktiver Auth den Bearer-Token aus '
'~/.chain/hub-auth-token hier eintragen.',
style: t.labelSmall
?.copyWith(color: LawHeatmapColors.mute),
),
const SizedBox(height: LawHeatmapSpace.lg),
Row(children: [
Expanded(
flex: 3,
child: TextField(
controller: _hostCtl,
decoration: const InputDecoration(
labelText: 'Host',
hintText: '127.0.0.1',
),
),
),
const SizedBox(width: LawHeatmapSpace.md),
Expanded(
child: TextField(
controller: _portCtl,
decoration: const InputDecoration(
labelText: 'Port',
hintText: '50051',
),
keyboardType: TextInputType.number,
),
),
]),
const SizedBox(height: LawHeatmapSpace.md),
TextField(
controller: _tokenCtl,
decoration: const InputDecoration(
labelText: 'Auth-Token (optional)',
),
obscureText: true,
),
const SizedBox(height: LawHeatmapSpace.md),
Row(children: [
Switch(
value: _secure,
onChanged: (v) => setState(() => _secure = v),
),
const SizedBox(width: LawHeatmapSpace.sm),
const Text('TLS (https)'),
const SizedBox(width: LawHeatmapSpace.xl),
Switch(
value: _useHub,
onChanged: (v) => setState(() => _useHub = v),
),
const SizedBox(width: LawHeatmapSpace.sm),
const Text('Hub beim Start nutzen'),
]),
const SizedBox(height: LawHeatmapSpace.lg),
Row(children: [
FilledButton.icon(
onPressed: _probing ? null : _probe,
icon: _probing
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.bolt_outlined),
label: const Text('Verbindung testen'),
),
const SizedBox(width: LawHeatmapSpace.md),
OutlinedButton.icon(
onPressed: _save,
icon: const Icon(Icons.save_outlined),
label: const Text('Speichern'),
),
]),
if (_probeMessage != null) ...[
const SizedBox(height: LawHeatmapSpace.md),
Text(_probeMessage!,
style: t.labelLarge?.copyWith(
color: LawHeatmapColors.signal,
)),
],
],
),
),
const SizedBox(height: LawHeatmapSpace.lg),
HubCapabilitiesCard(repository: repo),
const SizedBox(height: LawHeatmapSpace.lg),
LawHeatmapCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Geplanter Live-Flow', style: t.headlineSmall),
const SizedBox(height: LawHeatmapSpace.sm),
Text(
'1. `chain serve` auf localhost:50051 starten.\n'
'2. Module installieren (`chain install`).\n'
'3. Flow ausführen:\n'
' chain run flows/durchstich-gewo-14.yaml \\\n'
' --input norm_url=https://www.gesetze-im-internet.de/gewo/__14.xml \\\n'
' --input cohort_id=berlin-kmu\n'
'4. `chain admin approvals list` → '
'Juristen-Gate freigeben.\n'
'5. App neu starten — Hub-Modus aktiv, '
'flow.completed-Events werden in '
'Evaluation-Objekte übersetzt.',
style: t.bodyLarge?.copyWith(
height: 1.6,
fontFamily: LawHeatmapTypography.mono,
fontSize: 12,
),
),
],
),
),
],
),
);
}
Widget _row(String label, String value, TextTheme t) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 200,
child: Text(label,
style: t.labelLarge
?.copyWith(color: LawHeatmapColors.mute)),
),
Expanded(
child: Text(value,
style: t.bodyLarge
?.copyWith(fontFamily: LawHeatmapTypography.mono)),
),
],
),
);
}
}