feat(studio): operator-mode pass — Store + audit detail + channel switcher (v0.10.0)

Turns Studio from read-only dashboard into operator console.
Pieces:

  * **New Store page** (sidebar destination #3): browses the
    hub's bundled store-index with query + category + status
    filters. Each card has an Install button that prompts for
    the `.fai` bundle source (URL or local path), ships it to
    HubAdmin.InstallModule, shows success / error in a progress
    dialog, refreshes the list.

  * **Audit drill-down**: every event row is now tappable;
    opens a modal with all LoggedEvent fields including the
    new `detail` JSON pretty-printed in a code block. KRITIS
    forensic story: every audit row → full structured detail
    in two clicks.

  * **Approvals payload preview**: shipped already; now
    pretty-prints JSON when the preview parses as such, plus
    a clearer `PAYLOAD PREVIEW` label. Reviewer identity
    defaults to `$USER@studio` instead of the hard-coded
    `studio-mvp` so the audit trail records who acted.

  * **Channel switcher in Settings dialog**: the dialog now
    pulls HubAdmin.ChannelStatus and renders one row per
    channel (local / dev / beta / production) with port,
    running indicator, active marker. A "Connect" button
    on a running channel sets Studio's endpoint to that
    channel's port and reconnects in one click.

Cmd+1..6 keyboard shortcuts updated for the six destinations.
Widget test asserts every destination renders.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 11:54:31 +02:00
parent 18db6ff164
commit 060c8003d5
9 changed files with 990 additions and 12 deletions

View file

@ -106,6 +106,74 @@ class HubService {
);
}
/// Active channel + per-channel daemon status snapshot.
Future<ChannelStatusSnapshot> channelStatus() async {
final r = await _client.channelStatus();
return ChannelStatusSnapshot(
active: r.active,
channels: r.channels
.map(
(c) => ChannelInfo(
name: c.name,
port: c.port,
running: c.running,
endpoint: c.endpoint,
),
)
.toList(),
);
}
/// Browse the bundled store index. All filters optional;
/// empty query returns the first [limit] entries.
Future<List<StoreItem>> searchStore({
String query = '',
String category = '',
String tag = '',
String status = '',
int limit = 50,
}) async {
final entries = await _client.searchStore(
query: query,
category: category,
tag: tag,
status: status,
limit: limit,
);
return entries
.map(
(e) => StoreItem(
name: e.name,
taglineEn: e.taglineEn,
descriptionEn: e.descriptionEn,
category: e.category,
tags: e.tags,
requiresCapabilities: e.requiresCapabilities,
requiresServices: e.requiresServices,
license: e.license,
repository: e.repository,
bestVersion: e.bestVersion,
status: e.status,
installed: e.installed,
),
)
.toList();
}
/// Install a module from a `.fai` bundle (URL or local path).
/// Returns the installed module's name + version on success;
/// throws on hub error (signature mismatch, sha256 fail, etc.).
Future<({String name, String version})> installModule({
required String source,
String expectedSha256 = '',
}) async {
final r = await _client.installModule(
source: source,
expectedSha256: expectedSha256,
);
return (name: r.name, version: r.version);
}
/// Saved flows known to the hub.
Future<List<SavedFlow>> listFlows() async {
final flows = await _client.listFlows();
@ -163,8 +231,15 @@ class HubService {
flowName: e.flowName.isEmpty ? null : e.flowName,
stepId: e.stepId.isEmpty ? null : e.stepId,
moduleName: e.moduleName.isEmpty ? null : e.moduleName,
moduleVersion:
e.moduleVersion.isEmpty ? null : e.moduleVersion,
invocationId:
e.invocationId.isEmpty ? null : e.invocationId,
flowExecution:
e.flowExecution.isEmpty ? null : e.flowExecution,
durationMs: e.durationMs == 0 ? null : e.durationMs.toInt(),
error: e.error.isEmpty ? null : e.error,
detail: e.detail.isEmpty ? null : e.detail,
),
)
.toList();
@ -378,8 +453,14 @@ class AuditEvent {
final String? flowName;
final String? stepId;
final String? moduleName;
final String? moduleVersion;
final String? invocationId;
final String? flowExecution;
final int? durationMs;
final String? error;
/// Free-form JSON-string carrying event-type-specific fields.
/// Surfaced verbatim in the audit drill-down dialog.
final String? detail;
const AuditEvent({
required this.eventId,
@ -388,8 +469,12 @@ class AuditEvent {
this.flowName,
this.stepId,
this.moduleName,
this.moduleVersion,
this.invocationId,
this.flowExecution,
this.durationMs,
this.error,
this.detail,
});
}
@ -412,3 +497,59 @@ class PendingApproval {
this.expiresAt,
});
}
class ChannelInfo {
final String name;
final int port;
final bool running;
final String endpoint;
const ChannelInfo({
required this.name,
required this.port,
required this.running,
required this.endpoint,
});
}
class ChannelStatusSnapshot {
final String active;
final List<ChannelInfo> channels;
const ChannelStatusSnapshot({
required this.active,
required this.channels,
});
}
/// One row from the store-index search.
class StoreItem {
final String name;
final String taglineEn;
final String descriptionEn;
final String category;
final List<String> tags;
final List<String> requiresCapabilities;
final List<String> requiresServices;
final String license;
final String repository;
final String bestVersion;
/// "published", "alpha", "planned", or empty when unknown.
final String status;
final bool installed;
const StoreItem({
required this.name,
required this.taglineEn,
required this.descriptionEn,
required this.category,
required this.tags,
required this.requiresCapabilities,
required this.requiresServices,
required this.license,
required this.repository,
required this.bestVersion,
required this.status,
required this.installed,
});
}