home.html 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <pstm-uri>/</pstm-uri>
  5. <pstm-template>main</pstm-template>
  6. <script>
  7. // Live dashboard: the pulse subscription (see the entrypoint)
  8. // re-runs the sealed refresh action whenever task or saddle state
  9. // changes, so progress, blocks and review requests stream in.
  10. // The notification bell drives the Web Push subscribe flow (plan
  11. // 11 task E): permission -> service worker -> pushManager.subscribe
  12. // with the page's VAPID key -> the sealed push_subscribe action.
  13. if (window.statum) {
  14. (function () {
  15. 'use strict';
  16. statum.addStateListener('pulse', function () {
  17. var dashboard = statum.read('dashboard');
  18. if (dashboard && dashboard.refresh) {
  19. statum.act(dashboard.refresh).catch(function () { });
  20. }
  21. });
  22. function toBase64Url(buffer) {
  23. var bytes = new Uint8Array(buffer);
  24. var binary = '';
  25. for (var index = 0; index < bytes.length; index++) {
  26. binary += String.fromCharCode(bytes[index]);
  27. }
  28. return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  29. }
  30. function vapidKeyBytes(base64Url) {
  31. var padding = '='.repeat((4 - base64Url.length % 4) % 4);
  32. var base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
  33. var raw = atob(base64);
  34. var bytes = new Uint8Array(raw.length);
  35. for (var index = 0; index < raw.length; index++) {
  36. bytes[index] = raw.charCodeAt(index);
  37. }
  38. return bytes;
  39. }
  40. function dashboard() {
  41. return statum.read('dashboard') || {};
  42. }
  43. function fail(error) {
  44. if (window.showToast) {
  45. showToast('Notifications: ' + (error && error.message ? error.message : error), 'error');
  46. }
  47. }
  48. function enablePush() {
  49. var state = dashboard();
  50. if (!state.push_vapid_key) {
  51. fail('not available on this server');
  52. return;
  53. }
  54. if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
  55. fail('this browser has no Web Push support');
  56. return;
  57. }
  58. Notification.requestPermission().then(function (permission) {
  59. if (permission !== 'granted') {
  60. fail('permission denied in the browser');
  61. return;
  62. }
  63. // The service worker lives at the origin root (GET
  64. // /sw.js, Service-Worker-Allowed: /), so its scope
  65. // covers every page.
  66. return navigator.serviceWorker.register('/sw.js', { scope: '/' })
  67. .then(function () { return navigator.serviceWorker.ready; })
  68. .then(function (registration) {
  69. return registration.pushManager.getSubscription().then(function (existing) {
  70. if (existing) return existing;
  71. return registration.pushManager.subscribe({
  72. userVisibleOnly: true,
  73. applicationServerKey: vapidKeyBytes(state.push_vapid_key)
  74. });
  75. });
  76. })
  77. .then(function (subscription) {
  78. var keys = subscription.toJSON().keys || {};
  79. return statum.act(state.push_subscribe, {
  80. endpoint: subscription.endpoint,
  81. p256dh: keys.p256dh,
  82. auth: keys.auth
  83. });
  84. })
  85. .catch(function (error) { fail(error); });
  86. });
  87. }
  88. function disablePush() {
  89. var state = dashboard();
  90. navigator.serviceWorker.getRegistration('/').then(function (registration) {
  91. if (!registration) return null;
  92. return registration.pushManager.getSubscription();
  93. }).then(function (subscription) {
  94. var done = subscription ? subscription.unsubscribe() : Promise.resolve(true);
  95. return done.then(function () {
  96. if (subscription && state.push_unsubscribe) {
  97. return statum.act(state.push_unsubscribe, {
  98. endpoint: subscription.endpoint
  99. });
  100. }
  101. });
  102. }).catch(function (error) { fail(error); });
  103. }
  104. // ---- per-browser bell state (the user-global flag lied) ----
  105. // The dashboard exposes the user's endpoint FINGERPRINTS;
  106. // this browser's own pushManager subscription is hashed
  107. // the same way and looked up in the list. Each browser
  108. // then shows its own truth: subscribed here ("Turn off")
  109. // vs subscribed elsewhere only ("Enable" + a count). The
  110. // client-side check is authoritative - the server flag is
  111. // just a convenience for other consumers.
  112. var browserSubscribed = null; // null = still asking the browser
  113. var hereNow = false; // the displayed verdict (click acts on it)
  114. function sha256Hex16(text) {
  115. // Mirrors PushService.endpoint_fingerprint: first 8
  116. // bytes of the SHA-256, hex - crypto.subtle needs a
  117. // secure context, exactly like pushManager itself.
  118. var data = new TextEncoder().encode(text);
  119. return crypto.subtle.digest('SHA-256', data).then(function (digest) {
  120. var bytes = new Uint8Array(digest).slice(0, 8);
  121. var hex = '';
  122. for (var index = 0; index < bytes.length; index++) {
  123. hex += ('0' + bytes[index].toString(16)).slice(-2);
  124. }
  125. return hex;
  126. });
  127. }
  128. function myFingerprint() {
  129. if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
  130. return Promise.resolve(null);
  131. }
  132. return navigator.serviceWorker.getRegistration('/').then(function (registration) {
  133. if (!registration) return null;
  134. return registration.pushManager.getSubscription().then(function (sub) {
  135. return sub ? sha256Hex16(sub.endpoint) : null;
  136. });
  137. }).catch(function () { return null; });
  138. }
  139. function otherBrowserCount() {
  140. // The dashboard's fingerprints minus this browser's own.
  141. var mine = browserSubscribed;
  142. var list = dashboard().push_endpoints || [];
  143. if (mine === null || mine === undefined) return list.length;
  144. var others = 0;
  145. for (var index = 0; index < list.length; index++) {
  146. if (list[index] !== mine) others++;
  147. }
  148. return others;
  149. }
  150. function refreshBell() {
  151. return myFingerprint().then(function (mine) {
  152. browserSubscribed = mine;
  153. var list = dashboard().push_endpoints || [];
  154. var card = document.querySelector('.push-bell-card');
  155. if (!card) return;
  156. var here = mine !== null && list.indexOf(mine) !== -1;
  157. var others = otherBrowserCount();
  158. var onBtn = card.querySelector('[data-bell-on]');
  159. var offBtn = card.querySelector('[data-bell-off]');
  160. var help = card.querySelector('[data-bell-help]');
  161. var testBtn = card.querySelector('[data-push-test]');
  162. hereNow = here;
  163. if (onBtn) onBtn.hidden = here;
  164. if (offBtn) offBtn.hidden = !here;
  165. if (testBtn) testBtn.hidden = !here;
  166. if (help) {
  167. if (here) {
  168. help.textContent = others > 0
  169. ? '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; '
  170. + others + ' other browser(s) are also subscribed; "Notify me" per task adds its start, failure and completion'
  171. : '\u2014 this browser receives question, RFC, review and waiting-on-drafts alerts; "Notify me" per task adds its start, failure and completion';
  172. } else if (list.length > 0) {
  173. help.textContent = '\u2014 you are subscribed on ' + list.length
  174. + ' other browser(s); enable here too to be notified in this browser';
  175. } else {
  176. help.textContent = '\u2014 be told when an agent asks a question, raises an RFC or review';
  177. }
  178. }
  179. });
  180. }
  181. function sendTest() {
  182. var state = dashboard();
  183. if (!state.push_test) {
  184. fail('not available on this server');
  185. return;
  186. }
  187. statum.act(state.push_test, {}).catch(function (error) { fail(error); });
  188. }
  189. // The dashboard state refreshes on every pulse; the bell's
  190. // per-browser verdict must be re-derived each time (the
  191. // browser's own subscription may have changed under us).
  192. statum.addStateListener('dashboard', refreshBell);
  193. refreshBell();
  194. document.addEventListener('click', function (event) {
  195. if (!event.target.closest) return;
  196. var card = event.target.closest('.push-bell-card');
  197. if (!card) return;
  198. var bell = event.target.closest('[data-push-bell]');
  199. var test = event.target.closest('[data-push-test]');
  200. if (bell) {
  201. event.preventDefault();
  202. // Follow the DISPLAYED verdict: a browser holding a
  203. // subscription the server no longer knows (a pruned
  204. // row) shows Enable - and enabling re-registers the
  205. // existing subscription, healing it.
  206. if (hereNow) disablePush();
  207. else enablePush();
  208. } else if (test) {
  209. event.preventDefault();
  210. sendTest();
  211. }
  212. });
  213. })();
  214. }
  215. </script>
  216. </head>
  217. <body>
  218. <section>
  219. <h2>Dashboard</h2>
  220. <p stm-if="dashboard == null">Loading…</p>
  221. <div stm-if="dashboard != null">
  222. <!-- The notification bell (plan 11 task E): subscribe this
  223. browser (permission + service worker + VAPID key) or
  224. turn it off. Block events notify every subscribed user;
  225. per-task "Notify me" adds start/failure/completion. -->
  226. <!-- The notification bell (plan 11 task E; per-browser
  227. since the user-global flag lied to second browsers):
  228. THIS browser's own subscription decides Enable vs Turn
  229. off (the JS fingerprints the browser's endpoint and
  230. matches it against the dashboard's list), the helper
  231. text counts the user's OTHER subscribed browsers, and
  232. "Send test notification" fires the real end-to-end
  233. probe through this user's subscriptions. -->
  234. <section class="card push-bell-card">
  235. <form class="inline">
  236. <button type="button" data-push-bell data-bell-on class="primary"
  237. hidden>Enable notifications</button>
  238. <button type="button" data-push-bell data-bell-off
  239. hidden>Turn off notifications</button>
  240. <button type="button" data-push-test
  241. hidden>Send test notification</button>
  242. <span class="muted" data-bell-help>— be told when an agent asks a
  243. question, raises an RFC or review</span>
  244. </form>
  245. </section>
  246. <div class="cards">
  247. <section class="card">
  248. <h3>Queue</h3>
  249. <p><strong stm-text="dashboard.ready_count">0</strong> ready to dispatch</p>
  250. <p class="muted"><span stm-text="dashboard.queued_count">0</span> queued ·
  251. <span stm-text="dashboard.active_count">0</span> active ·
  252. <span stm-text="dashboard.blocked_count">0</span> blocked</p>
  253. <p class="muted"><span stm-text="dashboard.done_count">0</span> done ·
  254. <span stm-text="dashboard.failed_count">0</span> failed</p>
  255. </section>
  256. <section class="card">
  257. <h3>Saddles</h3>
  258. <p><strong stm-text="dashboard.idle_saddle_count">0</strong> idle</p>
  259. <p><a href="/saddles">Manage saddles</a> · <a href="/tasks">Task queue</a></p>
  260. </section>
  261. </div>
  262. <!-- The scratchpad (goal 8 task 3): jot down batches of
  263. changes before they are ready to be full-on goals.
  264. Check a note off, delete it, or promote it — promote
  265. creates a DRAFT goal (title = the first line, brief =
  266. the whole body) and jumps to its page to finish. -->
  267. <h3>Notes</h3>
  268. <section class="card">
  269. <form stm-action="dashboard.add_note">
  270. <div class="form-row">
  271. <textarea name="body" rows="2" required
  272. placeholder="Note down a batch of changes before it is ready to be a goal…"></textarea></div>
  273. <button type="submit">Add note</button>
  274. </form>
  275. </section>
  276. <table stm-if="dashboard.notes.length > 0">
  277. <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
  278. <tbody>
  279. <tr stm-for-note-in="dashboard.notes">
  280. <td><button type="button" stm-action="note.toggle"
  281. stm-attribute.class="note.done ? 'approve' : ''"
  282. stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
  283. <td><span stm-attribute.class="note.done ? 'footnote' : ''"
  284. stm-text="note.body">body</span></td>
  285. <td class="muted" stm-text="note.created_display">when</td>
  286. <td>
  287. <span stm-if="note.goal_id > 0">
  288. <span class="goal-icon">🎯</span>
  289. <a stm-attribute.href="'/goals/' + note.goal_id"
  290. stm-text="note.goal_title">goal</a>
  291. </span>
  292. <form class="inline" stm-if="note.goal_id == 0 && !note.done"
  293. stm-action="note.promote">
  294. <select name="project_id" required>
  295. <option stm-for-project-in="dashboard.projects"
  296. stm-attribute.value="project.id"
  297. stm-text="project.name">project</option>
  298. </select>
  299. <input type="text" name="branch_name" required
  300. placeholder="goal/branch" size="14">
  301. <button type="submit" class="primary">Create goal</button>
  302. </form>
  303. <span class="muted" stm-if="note.goal_id == 0 && note.done">—</span>
  304. <form class="inline" stm-action="note.delete">
  305. <button type="submit">Delete</button>
  306. </form>
  307. </td>
  308. </tr>
  309. </tbody>
  310. </table>
  311. <p stm-if="dashboard.notes.length == 0" class="muted">No notes yet — jot down the
  312. next batch of changes above.</p>
  313. <h3 stm-if="dashboard.pinned.length > 0">Needs a human
  314. (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
  315. <p class="muted" stm-if="dashboard.pinned.length > 0">Open questions, RFCs and
  316. reviews across all tasks and goals, newest first — follow the link to
  317. answer on the task or goal page.</p>
  318. <div class="cards" stm-if="dashboard.pinned.length > 0">
  319. <section stm-for-blockview-in="dashboard.pinned"
  320. stm-attribute.class="blockview.item_type == 'goal' ? 'card goal-card' : 'card'">
  321. <p>
  322. <span stm-if="blockview.item_type == 'goal'" class="goal-icon">🎯</span>
  323. <span stm-attribute.class="'badge badge-kind-' + blockview.item_type"
  324. stm-text="blockview.item_type">task</span>
  325. <span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
  326. <span class="muted" stm-text="blockview.created_display">when</span></p>
  327. <p><strong stm-text="blockview.question">question</strong></p>
  328. <p stm-if="blockview.item_type == 'task'">
  329. <a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
  330. <p stm-if="blockview.item_type == 'goal'">
  331. <a stm-attribute.href="'/goals/' + blockview.goal_id" stm-text="blockview.goal_title">goal</a></p>
  332. </section>
  333. </div>
  334. <p stm-if="dashboard.pinned.length == 0" class="muted">Nothing needs a human —
  335. no open questions, RFCs or reviews.</p>
  336. <h3 stm-if="dashboard.active_tasks.length > 0">Active tasks</h3>
  337. <table stm-if="dashboard.active_tasks.length > 0">
  338. <thead><tr><th>Task</th><th>Status</th><th>Branch</th><th>Latest progress</th></tr></thead>
  339. <tbody>
  340. <tr stm-for-active-in="dashboard.active_tasks">
  341. <td><a stm-attribute.href="'/tasks/' + active.id" stm-text="active.title">task</a></td>
  342. <td><span stm-attribute.class="'badge badge-' + active.status" stm-text="active.status">status</span></td>
  343. <td stm-text="active.task_branch">branch</td>
  344. <td><strong stm-if="active.latest_progress.length > 0" stm-text="active.latest_progress">progress</strong>
  345. <span class="muted" stm-if="active.latest_progress.length > 0" stm-text="active.progress_display">when</span>
  346. <span class="muted" stm-if="active.latest_progress.length == 0">—</span></td>
  347. </tr>
  348. </tbody>
  349. </table>
  350. <h3>Saddles</h3>
  351. <div class="cards">
  352. <section class="card" stm-for-saddle-in="dashboard.saddles">
  353. <h3 stm-text="saddle.name">name</h3>
  354. <p><span stm-attribute.class="'badge badge-' + saddle.status" stm-text="saddle.status">status</span>
  355. <span class="muted" stm-text="saddle.host">host</span></p>
  356. <p stm-if="saddle.current_goal_id > 0">Supervising goal
  357. <a stm-attribute.href="'/goals/' + saddle.current_goal_id"
  358. stm-text="saddle.current_goal_title">goal</a>
  359. <span class="badge badge-kind-goal">goal</span></p>
  360. <p stm-if="saddle.current_task_id > 0 && saddle.current_goal_id == 0">Working on
  361. <a stm-attribute.href="'/tasks/' + saddle.current_task_id" stm-text="saddle.current_task_title">task</a></p>
  362. <p stm-if="saddle.current_task_id == 0" class="muted">No current task</p>
  363. </section>
  364. <section class="card" stm-if="dashboard.saddles.length == 0">
  365. <p class="muted">No saddles registered.</p>
  366. </section>
  367. </div>
  368. <h3>Activity</h3>
  369. <p class="muted">The last <span stm-text="dashboard.activity.length">0</span> events
  370. across all tasks, newest first.</p>
  371. <table>
  372. <thead><tr><th>When</th><th>Task</th><th>Event</th></tr></thead>
  373. <tbody>
  374. <tr stm-for-item-in="dashboard.activity">
  375. <td stm-text="item.when_display">when</td>
  376. <td stm-if="item.goal_id > 0">
  377. <span class="badge badge-kind-goal">goal</span>
  378. <a stm-attribute.href="'/goals/' + item.goal_id" stm-text="item.goal_title">goal</a>
  379. <a stm-if="item.task_id > 0" class="muted"
  380. stm-attribute.href="'/tasks/' + item.task_id"
  381. >&rarr; <span stm-text="'task #' + item.task_id">task</span></a></td>
  382. <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
  383. <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>
  384. <!-- Comment rows read as footnotes (plan 13
  385. B4): the body leads, muted and smaller. -->
  386. <span stm-attribute.class="item.kind == 'comment' || item.kind == 'goal-comment' || item.kind == 'goal-system' ? 'footnote' : ''"
  387. stm-text="item.text">activity</span></td>
  388. </tr>
  389. <tr stm-if="dashboard.activity.length == 0"><td colspan="3" class="muted">Nothing yet.</td></tr>
  390. </tbody>
  391. </table>
  392. </div>
  393. </section>
  394. </body>
  395. </html>