using Invercargill; using Invercargill.DataStructures; using Invercargill.Expressions; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Data; using ClankerSaddle.Services; namespace ClankerSaddle { /** "3m ago"-style compact duration for the health panel. */ private static string relative_time(int64 delta_ms) { int64 seconds = delta_ms.clamp(0, int64.MAX) / 1000; if (seconds < 60) { return @"$(seconds)s"; } if (seconds < 3600) { return @"$(seconds / 60)m"; } return @"$(seconds / 3600)h"; } /** A capability chip on a saddle card, with its remove action. */ public class SaddleCapabilityView : Object { public int64 link_id { get; set; } public string name { get; set; default = ""; } public Statum.Model.ActionDto? detach { get; set; } } /** A saddle card on the management page, with its current task and API token. */ public class SaddleRowView : Object { public int64 id { get; set; } public string name { get; set; default = ""; } public string host { get; set; default = ""; } public string status { get; set; default = ""; } public string health { get; set; default = "unknown"; } public string daemon_state { get; set; default = ""; } public string uptime_display { get; set; default = ""; } public string last_contact_display { get; set; default = ""; } public string git_name { get; set; default = ""; } public string git_email { get; set; default = ""; } public string ssh_public_key { get; set; default = ""; } public Statum.Model.ActionDto? update_identity { get; set; } public int64 current_task_id { get; set; } public string current_task_title { get; set; default = ""; } /** Set when the current task is a goal agent's run: link the goal instead. */ public int64 current_goal_id { get; set; } public string current_goal_title { get; set; default = ""; } public string allocated_display { get; set; default = ""; } public string token { get; set; default = ""; } public Statum.Model.ActionDto? unregister { get; set; } public Statum.Model.ActionDto? refresh { get; set; } public Statum.Model.ActionDto? attach_capability { get; set; } } /** A capability row in the saddles page's capabilities section. */ public class CapabilityRowView : Object { public int64 id { get; set; } public string name { get; set; default = ""; } public int64 saddle_count { get; set; } public int64 task_count { get; set; } public Statum.Model.ActionDto? delete { get; set; } } /** An option in the attach selects (saddle cards and the task page). */ public class CapabilityOptionView : Object { public int64 id { get; set; } public string name { get; set; default = ""; } } /** Public data of the `saddles` slot. */ public class SaddlesPublic : Object { /** True when the viewer may see the admin controls (the actions * re-check the permission; this only shapes the page). */ public bool is_admin { get; set; } public Statum.Model.ActionDto register { get; set; } public Statum.Model.ActionDto create_capability { get; set; } } /** * Entrypoint for /saddles: the saddle inventory plus the capability * tag catalogue. Admin-only (the authoritative guard); each card * carries its current task, capability chips (with remove actions), * the attach-capability select, and the unregister/refresh actions. */ public class SaddlesEntrypoint : StatumEntrypoint { private SaddleService saddle_service = Inversion.inject(); private SaddlePollState poll_state = Inversion.inject(); private CapabilityService capability_service = Inversion.inject(); private InvercargillSql.Orm.OrmSession db = Inversion.inject(); private UserService users = Inversion.inject(); public override async DirectiveBuilder handle() throws GLib.Error { // Viewing is open to every logged-in user (the roles // decision); the page's controls are admin-only, so the // public slot carries the admin flag the template's stm-if // guards bind against. The actions themselves enforce the // permission server-side. var guard = yield SpryAuth.require_login(directives(), request, users); if (guard != null) { return (!)guard; } bool is_admin = SpryAuth.current(request).has_permission(SpryAuth.ADMIN_PERMISSION); var capabilities = yield capability_service.list_capabilities(); var capability_names = new Dictionary(); foreach (var capability in capabilities) { capability_names.set(capability.id.to_string(), capability.name); } var saddle_links = yield capability_service.all_saddle_attachments(); var task_links = yield capability_service.all_task_attachments(); var options = new Vector(); foreach (var capability in capabilities) { options.add(new CapabilityOptionView() { id = capability.id, name = capability.name }); } // Per-saddle chips keyed by saddle id, attached to the row // models via list_rich below (slot lists cannot nest). var chips_by_saddle = new Dictionary>(); foreach (var link in saddle_links) { string name = capability_names.get_or_default(link.capability_id.to_string()) ?? "?"; var sealed_link = new SaddleCapabilityActionPrivate() { saddle_id = link.saddle_id, link_id = link.id }; var chips = chips_by_saddle.get_or_default(link.saddle_id.to_string()); if (chips == null) { chips = new Vector(); chips_by_saddle.set(link.saddle_id.to_string(), chips); } ((!)chips).add(new SaddleCapabilityView() { link_id = link.id, name = name, detach = action_registry.author_private(sealed_link) }); } var rows = new Vector(); int64 now_ms = get_monotonic_time() / 1000; foreach (var saddle in yield saddle_service.list_saddles()) { string daemon_state = ""; int64 uptime = 0; int64 activity = 0; int64 contact_ms = 0; poll_state.health_for(saddle.id, out daemon_state, out uptime, out activity, out contact_ms); string health = "unknown"; if (saddle.status == SADDLE_OFFLINE) { health = "offline"; } else if (daemon_state.length > 0) { health = "healthy"; if (daemon_state == "working" && activity > 0 && now_ms / 1000 - activity >= SaddleService.stale_after_seconds()) { health = "stale"; } } string last_contact = contact_ms > 0 ? relative_time(now_ms - contact_ms) : "never"; var sealed = new SaddleActionPrivate() { saddle_id = saddle.id }; // Plan 16 item 23: the row reads the DERIVED view — // busy iff a running session exists (its started_at is // the allocation stamp), else the stored OFFLINE probe // state. var work = yield saddle_service.current_work_view(saddle); var view = new SaddleRowView() { id = saddle.id, name = saddle.name, host = saddle.host, status = SaddleService.derived_status(saddle, work), health = health, daemon_state = daemon_state, uptime_display = uptime > 0 ? relative_time(uptime * 1000) : "", last_contact_display = last_contact, git_name = saddle.git_name ?? "", git_email = saddle.git_email ?? "", ssh_public_key = saddle.ssh_public_key ?? "", update_identity = action_registry.author_private(sealed), current_task_id = work != null ? ((!)work).task_id : 0, allocated_display = work != null ? format_stamp(((!)work).started_at) : "", unregister = action_registry.author_private(sealed), refresh = action_registry.author_private(sealed), attach_capability = action_registry.author_private(sealed) }; view.token = saddle.token ?? ""; if (work != null) { view.current_task_title = ((!)work).task_title; if (((!)work).goal_id > 0) { view.current_goal_id = ((!)work).goal_id; view.current_goal_title = ((!)work).goal_title; } } rows.add(view); } var capability_rows = new Vector(); foreach (var capability in capabilities) { int64 saddle_count = 0; foreach (var link in saddle_links) { if (link.capability_id == capability.id) { saddle_count++; } } int64 task_count = 0; foreach (var link in task_links) { if (link.capability_id == capability.id) { task_count++; } } var sealed_capability = new CapabilityActionPrivate() { capability_id = capability.id }; capability_rows.add(new CapabilityRowView() { id = capability.id, name = capability.name, saddle_count = saddle_count, task_count = task_count, delete = action_registry.author_private(sealed_capability) }); } var pub = new SaddlesPublic(); pub.is_admin = is_admin; pub.register = action_registry.author(); pub.create_capability = action_registry.author(); var props = (PropertyDictionary) GObjectMapping.to_properties(pub); props["saddles"] = StateJson.list_rich(rows.to_immutable_buffer(), (item, dict) => { var chips = chips_by_saddle.get_or_default(((SaddleRowView) item).id.to_string()); dict["capabilities"] = chips != null ? StateJson.list(((!)chips).to_immutable_buffer()) : StateJson.list( new Vector().to_immutable_buffer()); }); props["capabilities"] = StateJson.list(capability_rows.to_immutable_buffer()); props["capability_options"] = StateJson.list(options.to_immutable_buffer()); var slot = state_service.new_slot(Scope.PAGE, new State() { type_name = "saddles", public_data = props, private_data = new PropertyDictionary() }); return directives().set(slot); } } }