SaddlesEntrypoint.vala 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using Spry;
  5. using Spry.Authentication;
  6. using Statum;
  7. using ClankerSaddle.Data;
  8. using ClankerSaddle.Services;
  9. namespace ClankerSaddle {
  10. /** "3m ago"-style compact duration for the health panel. */
  11. private static string relative_time(int64 delta_ms) {
  12. int64 seconds = delta_ms.clamp(0, int64.MAX) / 1000;
  13. if (seconds < 60) {
  14. return @"$(seconds)s";
  15. }
  16. if (seconds < 3600) {
  17. return @"$(seconds / 60)m";
  18. }
  19. return @"$(seconds / 3600)h";
  20. }
  21. /** A capability chip on a saddle card, with its remove action. */
  22. public class SaddleCapabilityView : Object {
  23. public int64 link_id { get; set; }
  24. public string name { get; set; default = ""; }
  25. public Statum.Model.ActionDto? detach { get; set; }
  26. }
  27. /** A saddle card on the management page, with its current task and API token. */
  28. public class SaddleRowView : Object {
  29. public int64 id { get; set; }
  30. public string name { get; set; default = ""; }
  31. public string host { get; set; default = ""; }
  32. public string status { get; set; default = ""; }
  33. public string health { get; set; default = "unknown"; }
  34. public string daemon_state { get; set; default = ""; }
  35. public string uptime_display { get; set; default = ""; }
  36. public string last_contact_display { get; set; default = ""; }
  37. public string git_name { get; set; default = ""; }
  38. public string git_email { get; set; default = ""; }
  39. public string ssh_public_key { get; set; default = ""; }
  40. public Statum.Model.ActionDto? update_identity { get; set; }
  41. public int64 current_task_id { get; set; }
  42. public string current_task_title { get; set; default = ""; }
  43. /** Set when the current task is a goal agent's run: link the goal instead. */
  44. public int64 current_goal_id { get; set; }
  45. public string current_goal_title { get; set; default = ""; }
  46. public string allocated_display { get; set; default = ""; }
  47. public string token { get; set; default = ""; }
  48. public Statum.Model.ActionDto? unregister { get; set; }
  49. public Statum.Model.ActionDto? refresh { get; set; }
  50. public Statum.Model.ActionDto? attach_capability { get; set; }
  51. }
  52. /** A capability row in the saddles page's capabilities section. */
  53. public class CapabilityRowView : Object {
  54. public int64 id { get; set; }
  55. public string name { get; set; default = ""; }
  56. public int64 saddle_count { get; set; }
  57. public int64 task_count { get; set; }
  58. public Statum.Model.ActionDto? delete { get; set; }
  59. }
  60. /** An option in the attach selects (saddle cards and the task page). */
  61. public class CapabilityOptionView : Object {
  62. public int64 id { get; set; }
  63. public string name { get; set; default = ""; }
  64. }
  65. /** Public data of the `saddles` slot. */
  66. public class SaddlesPublic : Object {
  67. /** True when the viewer may see the admin controls (the actions
  68. * re-check the permission; this only shapes the page). */
  69. public bool is_admin { get; set; }
  70. public Statum.Model.ActionDto register { get; set; }
  71. public Statum.Model.ActionDto create_capability { get; set; }
  72. }
  73. /**
  74. * Entrypoint for /saddles: the saddle inventory plus the capability
  75. * tag catalogue. Admin-only (the authoritative guard); each card
  76. * carries its current task, capability chips (with remove actions),
  77. * the attach-capability select, and the unregister/refresh actions.
  78. */
  79. public class SaddlesEntrypoint : StatumEntrypoint {
  80. private SaddleService saddle_service = Inversion.inject<SaddleService>();
  81. private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
  82. private CapabilityService capability_service = Inversion.inject<CapabilityService>();
  83. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  84. private UserService users = Inversion.inject<UserService>();
  85. public override async DirectiveBuilder handle() throws GLib.Error {
  86. // Viewing is open to every logged-in user (the roles
  87. // decision); the page's controls are admin-only, so the
  88. // public slot carries the admin flag the template's stm-if
  89. // guards bind against. The actions themselves enforce the
  90. // permission server-side.
  91. var guard = yield SpryAuth.require_login(directives(), request, users);
  92. if (guard != null) {
  93. return (!)guard;
  94. }
  95. bool is_admin = SpryAuth.current(request).has_permission(SpryAuth.ADMIN_PERMISSION);
  96. var capabilities = yield capability_service.list_capabilities();
  97. var capability_names = new Dictionary<string, string>();
  98. foreach (var capability in capabilities) {
  99. capability_names.set(capability.id.to_string(), capability.name);
  100. }
  101. var saddle_links = yield capability_service.all_saddle_attachments();
  102. var task_links = yield capability_service.all_task_attachments();
  103. var options = new Vector<CapabilityOptionView>();
  104. foreach (var capability in capabilities) {
  105. options.add(new CapabilityOptionView() {
  106. id = capability.id,
  107. name = capability.name
  108. });
  109. }
  110. // Per-saddle chips keyed by saddle id, attached to the row
  111. // models via list_rich below (slot lists cannot nest).
  112. var chips_by_saddle = new Dictionary<string, Vector<SaddleCapabilityView>>();
  113. foreach (var link in saddle_links) {
  114. string name = capability_names.get_or_default(link.capability_id.to_string()) ?? "?";
  115. var sealed_link = new SaddleCapabilityActionPrivate() {
  116. saddle_id = link.saddle_id,
  117. link_id = link.id
  118. };
  119. var chips = chips_by_saddle.get_or_default(link.saddle_id.to_string());
  120. if (chips == null) {
  121. chips = new Vector<SaddleCapabilityView>();
  122. chips_by_saddle.set(link.saddle_id.to_string(), chips);
  123. }
  124. ((!)chips).add(new SaddleCapabilityView() {
  125. link_id = link.id,
  126. name = name,
  127. detach = action_registry.author_private<DetachSaddleCapabilityAction, SaddleCapabilityActionPrivate>(sealed_link)
  128. });
  129. }
  130. var rows = new Vector<SaddleRowView>();
  131. int64 now_ms = get_monotonic_time() / 1000;
  132. foreach (var saddle in yield saddle_service.list_saddles()) {
  133. string daemon_state = "";
  134. int64 uptime = 0;
  135. int64 activity = 0;
  136. int64 contact_ms = 0;
  137. poll_state.health_for(saddle.id, out daemon_state, out uptime,
  138. out activity, out contact_ms);
  139. string health = "unknown";
  140. if (saddle.status == SADDLE_OFFLINE) {
  141. health = "offline";
  142. } else if (daemon_state.length > 0) {
  143. health = "healthy";
  144. if (daemon_state == "working" && activity > 0
  145. && now_ms / 1000 - activity
  146. >= SaddleService.stale_after_seconds()) {
  147. health = "stale";
  148. }
  149. }
  150. string last_contact = contact_ms > 0
  151. ? relative_time(now_ms - contact_ms) : "never";
  152. var sealed = new SaddleActionPrivate() { saddle_id = saddle.id };
  153. // Plan 16 item 23: the row reads the DERIVED view —
  154. // busy iff a running session exists (its started_at is
  155. // the allocation stamp), else the stored OFFLINE probe
  156. // state.
  157. var work = yield saddle_service.current_work_view(saddle);
  158. var view = new SaddleRowView() {
  159. id = saddle.id,
  160. name = saddle.name,
  161. host = saddle.host,
  162. status = SaddleService.derived_status(saddle, work),
  163. health = health,
  164. daemon_state = daemon_state,
  165. uptime_display = uptime > 0 ? relative_time(uptime * 1000) : "",
  166. last_contact_display = last_contact,
  167. git_name = saddle.git_name ?? "",
  168. git_email = saddle.git_email ?? "",
  169. ssh_public_key = saddle.ssh_public_key ?? "",
  170. update_identity = action_registry.author_private<UpdateSaddleIdentityAction, SaddleActionPrivate>(sealed),
  171. current_task_id = work != null ? ((!)work).task_id : 0,
  172. allocated_display = work != null
  173. ? format_stamp(((!)work).started_at) : "",
  174. unregister = action_registry.author_private<UnregisterSaddleAction, SaddleActionPrivate>(sealed),
  175. refresh = action_registry.author_private<RefreshSaddleStatusAction, SaddleActionPrivate>(sealed),
  176. attach_capability = action_registry.author_private<AttachSaddleCapabilityAction, SaddleActionPrivate>(sealed)
  177. };
  178. view.token = saddle.token ?? "";
  179. if (work != null) {
  180. view.current_task_title = ((!)work).task_title;
  181. if (((!)work).goal_id > 0) {
  182. view.current_goal_id = ((!)work).goal_id;
  183. view.current_goal_title = ((!)work).goal_title;
  184. }
  185. }
  186. rows.add(view);
  187. }
  188. var capability_rows = new Vector<CapabilityRowView>();
  189. foreach (var capability in capabilities) {
  190. int64 saddle_count = 0;
  191. foreach (var link in saddle_links) {
  192. if (link.capability_id == capability.id) {
  193. saddle_count++;
  194. }
  195. }
  196. int64 task_count = 0;
  197. foreach (var link in task_links) {
  198. if (link.capability_id == capability.id) {
  199. task_count++;
  200. }
  201. }
  202. var sealed_capability = new CapabilityActionPrivate() { capability_id = capability.id };
  203. capability_rows.add(new CapabilityRowView() {
  204. id = capability.id,
  205. name = capability.name,
  206. saddle_count = saddle_count,
  207. task_count = task_count,
  208. delete = action_registry.author_private<DeleteCapabilityAction, CapabilityActionPrivate>(sealed_capability)
  209. });
  210. }
  211. var pub = new SaddlesPublic();
  212. pub.is_admin = is_admin;
  213. pub.register = action_registry.author<RegisterSaddleAction>();
  214. pub.create_capability = action_registry.author<CreateCapabilityAction>();
  215. var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
  216. props["saddles"] = StateJson.list_rich<SaddleRowView>(rows.to_immutable_buffer(),
  217. (item, dict) => {
  218. var chips = chips_by_saddle.get_or_default(((SaddleRowView) item).id.to_string());
  219. dict["capabilities"] = chips != null
  220. ? StateJson.list<SaddleCapabilityView>(((!)chips).to_immutable_buffer())
  221. : StateJson.list<SaddleCapabilityView>(
  222. new Vector<SaddleCapabilityView>().to_immutable_buffer());
  223. });
  224. props["capabilities"] = StateJson.list<CapabilityRowView>(capability_rows.to_immutable_buffer());
  225. props["capability_options"] = StateJson.list<CapabilityOptionView>(options.to_immutable_buffer());
  226. var slot = state_service.new_slot(Scope.PAGE, new State() {
  227. type_name = "saddles",
  228. public_data = props,
  229. private_data = new PropertyDictionary()
  230. });
  231. return directives().set(slot);
  232. }
  233. }
  234. }