@monster-orange/den 0.4.16 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +94 -1
  4. package/package.json +1 -1
  5. package/src/ui/cli/agent-ops.ts +50 -0
  6. package/src/ui/cli/agents-command.ts +5 -5
  7. package/src/ui/server/agent-routes.ts +13 -1
  8. package/src/ui/server/check-pool.ts +72 -0
  9. package/src/ui/server/index.ts +105 -10
  10. package/src/ui/server/public/agents-page.js +98 -16
  11. package/src/ui/server/public/agents.html +22 -0
  12. package/src/ui/server/public/environments.html +223 -140
  13. package/src/ui/server/public/graph.css +40 -0
  14. package/src/ui/server/public/graph.js +47 -6
  15. package/src/ui/server/public/header.js +7 -0
  16. package/src/ui/server/public/i18n/es.js +46 -0
  17. package/src/ui/server/public/i18n/ja.js +46 -0
  18. package/src/ui/server/public/index.html +6 -0
  19. package/src/ui/server/public/local-dev.html +199 -27
  20. package/src/ui/server/public/log-source.js +26 -0
  21. package/src/ui/server/public/logs.html +7 -20
  22. package/src/ui/server/public/panel.js +1 -1
  23. package/src/ui/server/public/release.html +19 -1
  24. package/src/ui/server/public/services.html +18 -3
  25. package/src/ui/server/public/shell.js +360 -32
  26. package/src/ui/server/public/svc-panel.js +672 -0
  27. package/src/ui/server/public/topbar.js +34 -1
  28. package/src/ui/server/public/view-scope.js +61 -0
  29. package/src/ui/server/public/workspace-picker.js +2 -0
  30. package/src/ui/server/route-classes.ts +2 -0
  31. package/src/ui/server/run-history.ts +181 -0
  32. package/src/ui/server/session-routes.ts +94 -17
  33. package/src/ui/server/ui-assets.ts +6 -0
@@ -50,6 +50,43 @@
50
50
  "b.down": "{n} 件停止",
51
51
  "act.pin": "ピン留め",
52
52
  "act.unpin": "ピン留めを外す",
53
+ "act.visit": "開く",
54
+ "act.visitX": "{s} を開く(新しいタブ)",
55
+ // 0.4.18: the service panel (svc-panel.js) and Start's Folder.
56
+ "svp.label": "{s}: クイックパネル",
57
+ "svp.more": "{s} のクイックパネル",
58
+ "svp.notRunning": "停止中",
59
+ "svp.up": "稼働 {d}",
60
+ "svp.upNow": "稼働中",
61
+ "svp.down": "停止: {c}",
62
+ "svp.downNone": "停止",
63
+ "svp.starting": "起動中…",
64
+ "svp.stopped": "停止",
65
+ "svp.noUrl": "URL なし",
66
+ "svp.start": "開始",
67
+ "svp.stop": "停止",
68
+ "svp.restart": "再起動",
69
+ "svp.logs": "ログ",
70
+ "svp.stopping": "{s} を停止中",
71
+ "svp.tests": "前回のテスト",
72
+ "svp.testsOk": "✓ {t}",
73
+ "svp.testsBad": "✗ 失敗 {t}",
74
+ "svp.testsRun": "実行中…",
75
+ "svp.testsNone": "—",
76
+ "svp.runTests": "テストを実行",
77
+ "svp.testsDone": "テスト成功: {s}",
78
+ "svp.testsFailed": "テスト失敗: {s}",
79
+ "svp.agents": "エージェント",
80
+ "svp.noAgents": "このリポジトリにエージェントはいません。",
81
+ "svp.open": "開く",
82
+ "svp.openAg": "{s} を開く",
83
+ "svp.startHere": "ここでエージェントを開始",
84
+ "svp.openSvc": "サービスで開く",
85
+ "svp.openGraph": "グラフで開く",
86
+ "svp.close": "閉じる",
87
+ "ag.new.folder": "フォルダー(任意)",
88
+ "ag.new.folderHelp":
89
+ "ワークスペースのフォルダー内。空欄ならワークスペースのフォルダー。",
53
90
  "act.pinX": "{s} をピン留め",
54
91
  "act.unpinX": "{s} のピン留めを外す",
55
92
  "toast.pinned": "{s} をピン留めしました",
@@ -101,6 +138,8 @@
101
138
  "kind.app": "アプリ",
102
139
  "kind.action": "操作",
103
140
  "kind.setting": "設定",
141
+ "kind.workspace": "ワークスペース",
142
+ "pal.goWs": "ワークスペース {ws} へ移動",
104
143
  "pal.repos": "{n} 個のリポジトリ",
105
144
  "pal.restart": "{s} を再起動",
106
145
  "pal.results": "結果",
@@ -500,6 +539,10 @@
500
539
  "作業中です。このターンの後に変更しますか、それとも今すぐ停止しますか?",
501
540
  "ag.pc.after": "このターンの後",
502
541
  "ag.pc.now": "今すぐ停止",
542
+ "ag.pc.fresh":
543
+ "{id} は {p} になりました。まだ再開するものがないため、新しく開始しました(会話 ID、フォルダー、範囲は同じ)",
544
+ "ag.freshLine":
545
+ "まだ再開するものがないため、新しく開始しました(会話 ID、フォルダー、範囲は同じ)",
503
546
  "ag.pc.done": "{id} は {p} になりました",
504
547
  "ag.pc.pending": "このターンが終わると {id} は {p} になります",
505
548
  "ag.more": "{id} のその他の操作",
@@ -653,6 +696,9 @@
653
696
  "ag.ended": "このセッションは終了しました。記録は再開か削除まで残ります。",
654
697
  "ag.switched": "{p} に切り替えました",
655
698
  "ag.exitedLine": "[セッションが終了しました{code}]",
699
+ "ag.refusedUnknown": "サーバーがターミナルを拒否しました",
700
+ "ag.refusedStatus": "サーバーがターミナルを拒否しました({status})",
701
+ "ag.goWs": "ワークスペースで {ws} を開く",
656
702
  "ag.lost": "[切断: {why}]",
657
703
  "ag.reconnect": "再接続",
658
704
  "ag.noBackend":
@@ -290,6 +290,7 @@
290
290
  }
291
291
  .module-picker-dropdown {
292
292
  display: none;
293
+ padding: 0;
293
294
  position: absolute;
294
295
  top: calc(100% + 4px);
295
296
  left: 0;
@@ -1568,6 +1569,11 @@
1568
1569
  e.stopPropagation();
1569
1570
  const open = pickerDropdown.classList.toggle("open");
1570
1571
  pickerBtn.classList.toggle("open", open);
1572
+ // 0.4.17: in the top layer, so the output pane can't cover it.
1573
+ if (open)
1574
+ window.denShell?.topLayer?.show(pickerDropdown, pickerBtn, {
1575
+ gap: 4,
1576
+ });
1571
1577
  });
1572
1578
  document.addEventListener("click", () => {
1573
1579
  pickerDropdown.classList.remove("open");
@@ -1168,6 +1168,11 @@
1168
1168
  }
1169
1169
  .tablewrap {
1170
1170
  overflow-x: auto;
1171
+ /* 0.4.17: in a short window (under ~620 px tall) the column's other
1172
+ parts squeezed it to nothing; it keeps room for rows and scrolls. */
1173
+ min-height: 200px;
1174
+ flex-shrink: 0;
1175
+ overflow-y: auto;
1171
1176
  border: 1px solid var(--border);
1172
1177
  border-radius: var(--radius);
1173
1178
  background: var(--surface);
@@ -1285,6 +1290,9 @@
1285
1290
  .tbl-health.ok {
1286
1291
  color: var(--ok);
1287
1292
  }
1293
+ .tbl-up:not(:empty) {
1294
+ margin-left: 8px;
1295
+ }
1288
1296
  .tbl-muted {
1289
1297
  color: var(--muted);
1290
1298
  font-size: 12.5px;
@@ -1330,6 +1338,46 @@
1330
1338
  .tbl-acts .tbl-more-sp {
1331
1339
  display: inline-block;
1332
1340
  }
1341
+ /* Visit ↗ (0.4.17): before Start, so Start/Stop/Restart/Logs and ⋯
1342
+ still line up across rows whether a service has a URL or not. */
1343
+ .tbl-acts .tbl-visit {
1344
+ flex: none;
1345
+ padding-left: 6px;
1346
+ padding-right: 6px;
1347
+ text-decoration: none;
1348
+ }
1349
+ /* The table never scrolls sideways: in a window under 1600px wide
1350
+ (the workspace list beside it) Visit is its ↗; its name and title
1351
+ still say Visit. */
1352
+ @media (max-width: 1599px) {
1353
+ .tbl-visit .tbl-visit-w {
1354
+ display: none;
1355
+ }
1356
+ }
1357
+ table.svc a.repo-name {
1358
+ text-decoration: underline;
1359
+ text-decoration-color: var(--border-strong);
1360
+ text-underline-offset: 2px;
1361
+ }
1362
+ table.svc a.repo-name:hover {
1363
+ text-decoration-color: currentColor;
1364
+ }
1365
+ /* The favourite star (0.4.17): the shell's pins, as on Services. */
1366
+ .tbl-star {
1367
+ background: none;
1368
+ border: 1px solid transparent;
1369
+ border-radius: var(--radius-button);
1370
+ color: var(--faint);
1371
+ cursor: pointer;
1372
+ font-size: 13px;
1373
+ line-height: 1.2;
1374
+ padding: 0 2px;
1375
+ }
1376
+ .tbl-star:hover,
1377
+ .tbl-star[aria-pressed="true"] {
1378
+ color: var(--accent-text);
1379
+ background: var(--sunken);
1380
+ }
1333
1381
  .tbl-btn:disabled {
1334
1382
  opacity: 0.45;
1335
1383
  cursor: not-allowed;
@@ -1769,7 +1817,11 @@
1769
1817
  .querySelectorAll(".ws-menu-dropdown.open")
1770
1818
  .forEach((d) => d.classList.remove("open"));
1771
1819
  const menu = document.getElementById(menuBtn.dataset.menuId);
1772
- menu?.classList.toggle("open");
1820
+ if (menu?.classList.toggle("open"))
1821
+ window.denShell?.topLayer?.show(menu, menuBtn, {
1822
+ align: "right",
1823
+ gap: 2,
1824
+ });
1773
1825
  return;
1774
1826
  }
1775
1827
  // ↗ open in editor
@@ -1822,7 +1874,12 @@
1822
1874
  if (updateUrl) {
1823
1875
  const params = new URLSearchParams(location.search);
1824
1876
  params.set("ws", name);
1825
- history.replaceState(null, "", "?" + params.toString());
1877
+ // The hash stays (#focus=<id>: Open in Graph's card).
1878
+ history.replaceState(
1879
+ null,
1880
+ "",
1881
+ "?" + params.toString() + location.hash,
1882
+ );
1826
1883
  }
1827
1884
  document
1828
1885
  .querySelectorAll(".ws-item")
@@ -1858,8 +1915,16 @@
1858
1915
  if (d.id !== btn.dataset.menuId) d.style.display = "none";
1859
1916
  });
1860
1917
  const menu = document.getElementById(btn.dataset.menuId);
1861
- if (menu)
1862
- menu.style.display = menu.style.display === "none" ? "" : "none";
1918
+ if (menu) {
1919
+ const show = menu.style.display === "none";
1920
+ menu.style.display = show ? "" : "none";
1921
+ // 0.4.17: in the top layer, so no row or card clips it.
1922
+ if (show)
1923
+ window.denShell?.topLayer?.show(menu, btn, {
1924
+ align: "right",
1925
+ gap: 3,
1926
+ });
1927
+ }
1863
1928
  e.stopPropagation();
1864
1929
  return;
1865
1930
  }
@@ -2102,7 +2167,7 @@
2102
2167
  <th scope="col">Health</th>
2103
2168
  <th scope="col">Branch</th>
2104
2169
  <th scope="col">Mode</th>
2105
- <th scope="col">Last run</th>
2170
+ <th scope="col">Last test run</th>
2106
2171
  <th scope="col"><span class="tbl-sr">Actions</span></th>
2107
2172
  </tr></thead>
2108
2173
  </table>
@@ -2272,22 +2337,60 @@
2272
2337
  return [{ key: "", title: null, rows }];
2273
2338
  }
2274
2339
 
2275
- /** The last run (the runs dock's) of a repo's tab: "✓ 12:03", "running…", "—". */
2340
+ // 0.4.17: each repo's last test run, from the server's run history
2341
+ // (every browser's runs, kept across restarts), and when its
2342
+ // service started (the daemons list), shown apart from it.
2343
+ let _lastRuns = {};
2344
+ let _upSince = new Map();
2345
+ async function loadLastRuns() {
2346
+ const [last, daemons] = await Promise.all([
2347
+ fetch("/api/runs/last")
2348
+ .then((r) => (r.ok ? r.json() : null))
2349
+ .catch(() => null),
2350
+ fetch("/api/service/daemons")
2351
+ .then((r) => (r.ok ? r.json() : []))
2352
+ .catch(() => []),
2353
+ ]);
2354
+ _lastRuns = last?.repos ?? {};
2355
+ _upSince = new Map();
2356
+ for (const d of Array.isArray(daemons) ? daemons : []) {
2357
+ const at = Date.parse(d.startedAt);
2358
+ if (!d.repoName || !Number.isFinite(at)) continue;
2359
+ const cur = _upSince.get(d.repoName);
2360
+ if (cur === undefined || at < cur) _upSince.set(d.repoName, at);
2361
+ }
2362
+ paintLastRuns();
2363
+ }
2364
+
2365
+ /** A repo's last test run: "✓ 12:03", "✗ failed 12:03", "running…", "—". */
2276
2366
  function lastRunText(repo) {
2277
- const list = runs()?.list?.() ?? [];
2278
- const mine = list.filter(
2279
- (r) => r.id === repo || r.id.split("/")[0] === repo,
2280
- );
2281
- const r = mine.sort((a, b) => (b.ranAt ?? 0) - (a.ranAt ?? 0))[0];
2282
- if (!r || (!r.ranAt && !r.running)) return { text: "—", cls: "" };
2283
- if (r.running) return { text: "running…", cls: "" };
2367
+ const r = _lastRuns[repo];
2368
+ if (!r) return { text: "—", cls: "", title: "" };
2369
+ const title = [r.pillar, r.env].filter(Boolean).join(" · ");
2370
+ if (r.running) return { text: "running…", cls: "", title };
2284
2371
  const t = new Intl.DateTimeFormat(undefined, {
2285
2372
  hour: "2-digit",
2286
2373
  minute: "2-digit",
2287
- }).format(new Date(r.endedAt ?? r.ranAt));
2288
- return r.error
2289
- ? { text: `✗ failed ${t}`, cls: "bad" }
2290
- : { text: `✓ ${t}`, cls: "ok" };
2374
+ }).format(new Date(r.endedAt ?? r.startedAt));
2375
+ return r.exitCode === 0
2376
+ ? { text: `✓ ${t}`, cls: "ok", title }
2377
+ : { text: `✗ failed ${t}`, cls: "bad", title };
2378
+ }
2379
+
2380
+ /** How long a repo's service has run: "up 2h", or "" when it doesn't. */
2381
+ function upText(repo) {
2382
+ const at = _upSince.get(repo);
2383
+ if (at === undefined) return "";
2384
+ const s = Math.max(0, Math.round((Date.now() - at) / 1000));
2385
+ const d =
2386
+ s < 60
2387
+ ? `${s}s`
2388
+ : s < 3600
2389
+ ? `${Math.floor(s / 60)}m`
2390
+ : s < 86400
2391
+ ? `${Math.floor(s / 3600)}h`
2392
+ : `${Math.floor(s / 86400)}d`;
2393
+ return `up ${d}`;
2291
2394
  }
2292
2395
 
2293
2396
  /** A health cell: the dot and the rollup, or the failing check. */
@@ -2335,6 +2438,47 @@
2335
2438
  );
2336
2439
  }
2337
2440
 
2441
+ /** A service's URL to visit (its edge URL if routed, else its local one), or null. */
2442
+ function visitUrl(svc) {
2443
+ const u = svc?.open;
2444
+ return typeof u === "string" && /^https?:\/\//.test(u) ? u : null;
2445
+ }
2446
+ /** The shell's words (its language), else English. */
2447
+ function shT(key, vars, en) {
2448
+ const v = window.denShell?.t?.(key, vars);
2449
+ return v && v !== key ? v : en;
2450
+ }
2451
+ /** Visit ↗ (0.4.17): opens the service in a new tab; nothing without a URL. */
2452
+ function visitBtn(svc, name) {
2453
+ const url = visitUrl(svc);
2454
+ if (!url) return "";
2455
+ const label = shT(
2456
+ "act.visitX",
2457
+ { s: name },
2458
+ `Visit ${name} (opens a new tab)`,
2459
+ );
2460
+ return `<a class="den-btn sm tbl-btn tbl-visit" href="${escHtml(url)}" target="_blank" rel="noopener" aria-label="${escHtml(label)}" title="${escHtml(url)}" data-k="visit:${escHtml(name)}"><span class="tbl-visit-w">${escHtml(shT("act.visit", {}, "Visit"))} </span>↗</a>`;
2461
+ }
2462
+ /** A row's name: a link to visit when the service has a URL. */
2463
+ function nameLink(svc, text) {
2464
+ const url = visitUrl(svc);
2465
+ return url
2466
+ ? `<a class="repo-name" href="${escHtml(url)}" target="_blank" rel="noopener" title="${escHtml(shT("act.visitX", { s: url }, `Visit ${url} (opens a new tab)`))}">${escHtml(text)}</a>`
2467
+ : `<span class="repo-name">${escHtml(text)}</span>`;
2468
+ }
2469
+ /** The favourite star (0.4.17): the shell's pins, the same store as Services. */
2470
+ function starBtn(id) {
2471
+ const sh = window.denShell;
2472
+ if (!sh?.isPinned) return "";
2473
+ const on = sh.isPinned(id);
2474
+ const label = shT(
2475
+ on ? "act.unpinX" : "act.pinX",
2476
+ { s: id },
2477
+ `${on ? "Unpin" : "Pin"} ${id}`,
2478
+ );
2479
+ return `<button type="button" class="tbl-star" data-pin-toggle="${escHtml(id)}" aria-pressed="${on ? "true" : "false"}" aria-label="${escHtml(label)}" title="${escHtml(label)}" data-k="star:${escHtml(id)}">${on ? "★" : "☆"}</button>`;
2480
+ }
2481
+
2338
2482
  /** Action buttons; each one's name says what and on which repo or service. */
2339
2483
  function actBtns(id, o) {
2340
2484
  const b = (act, label, name, extra = "") =>
@@ -2407,12 +2551,12 @@
2407
2551
  ? `<span class="tbl-mode"><span class="badge ${escHtml(mode)}">${escHtml(mode)}</span><button type="button" class="tbl-switch" data-action="tbl-mode" data-repo="${escHtml(repo)}" data-mode="${switchTo}" aria-label="${escHtml(repo)}: use ${mode === "managed" ? "local" : "managed"}" data-k="mode:${escHtml(repo)}">use ${mode === "managed" ? "local" : "managed"}</button></span>`
2408
2552
  : "";
2409
2553
  const head = `<tr class="repo${r.problem ? " prob" : ""}" data-repo="${escHtml(repo)}">
2410
- <th scope="row">${exp}<span class="repo-name">${escHtml(repo)}</span>${badges}</th>
2411
- <td>${repoHealth(r)}</td>
2554
+ <th scope="row">${exp}${nameLink(primary, repo)} ${starBtn(repo)}${badges}</th>
2555
+ <td>${repoHealth(r)}<span class="tbl-muted tbl-up" data-up="${escHtml(repo)}">${escHtml(upText(repo))}</span></td>
2412
2556
  <td class="tbl-mono tbl-muted">${escHtml(primary?.branch ?? "")}</td>
2413
2557
  <td>${modeCell}</td>
2414
- <td class="tbl-muted${run.cls ? " " + run.cls : ""}" data-last="${escHtml(repo)}">${escHtml(run.text)}</td>
2415
- <td><div class="tbl-acts">${actBtns(repo, { up: !!startOf(repo).length, restart: !!startOf(repo).length })}${repoMenu(r)}</div></td>
2558
+ <td class="tbl-muted${run.cls ? " " + run.cls : ""}" data-last="${escHtml(repo)}" title="${escHtml(run.title)}">${escHtml(run.text)}</td>
2559
+ <td><div class="tbl-acts">${visitBtn(primary, repo)}${actBtns(repo, { up: !!startOf(repo).length, restart: !!startOf(repo).length })}${repoMenu(r)}</div></td>
2416
2560
  </tr>`;
2417
2561
  const fixes = r.fixes
2418
2562
  .map(
@@ -2434,10 +2578,10 @@
2434
2578
  s,
2435
2579
  i,
2436
2580
  ) => `<tr class="sub" id="${subIds[i]}" data-repo="${escHtml(repo)}" data-id="${escHtml(s.id)}"${shown(s) ? "" : " hidden"}>
2437
- <th scope="row"><span class="repo-name">${escHtml(s.name)}</span>${s.entry ? ' <span class="badge">entry</span>' : ""}</th>
2581
+ <th scope="row">${nameLink(s, s.name)} ${starBtn(s.id)}${s.entry ? ' <span class="badge">entry</span>' : ""}</th>
2438
2582
  <td>${!_lastHealthAt ? CHECKING() : healthCell(status(s), s.health.ok === false ? checkLineOf(s) : healthLine(s))}</td>
2439
2583
  <td></td><td></td><td></td>
2440
- <td><div class="tbl-acts">${actBtns(s.id, { up: s.commands.includes("up"), restart: s.commands.includes("up") || s.commands.includes("restart") })}<span class="tbl-more-sp" aria-hidden="true"></span></div></td>
2584
+ <td><div class="tbl-acts">${visitBtn(s, s.id)}${actBtns(s.id, { up: s.commands.includes("up"), restart: s.commands.includes("up") || s.commands.includes("restart") })}<span class="tbl-more-sp" aria-hidden="true"></span></div></td>
2441
2585
  </tr>`,
2442
2586
  )
2443
2587
  .join("")
@@ -2516,13 +2660,17 @@
2516
2660
  if (keep)
2517
2661
  table.querySelector(`[data-k="${CSS.escape(keep)}"]`)?.focus();
2518
2662
  }
2519
- /** Only the Last run cells (the dock changed). */
2663
+ /** Only the Last test run and "up" cells (the history changed). */
2520
2664
  function paintLastRuns() {
2521
2665
  document.querySelectorAll("#repo-table [data-last]").forEach((td) => {
2522
2666
  const run = lastRunText(td.dataset.last);
2523
2667
  td.textContent = run.text;
2668
+ td.title = run.title;
2524
2669
  td.className = "tbl-muted" + (run.cls ? " " + run.cls : "");
2525
2670
  });
2671
+ document.querySelectorAll("#repo-table [data-up]").forEach((el) => {
2672
+ el.textContent = upText(el.dataset.up);
2673
+ });
2526
2674
  }
2527
2675
 
2528
2676
  /**
@@ -2581,6 +2729,14 @@
2581
2729
  setTableGroup(g.dataset.group);
2582
2730
  return;
2583
2731
  }
2732
+ const star = e.target.closest("#repo-table [data-pin-toggle]");
2733
+ if (star) {
2734
+ const sh = window.denShell;
2735
+ const id = star.dataset.pinToggle;
2736
+ if (sh?.isPinned(id)) sh.unpin(id);
2737
+ else sh?.pin(id);
2738
+ return;
2739
+ }
2584
2740
  const b = e.target.closest("[data-action^='tbl-']");
2585
2741
  if (!b || b.disabled) return;
2586
2742
  const a = b.dataset.action;
@@ -2641,8 +2797,20 @@
2641
2797
  const v = localStorage.getItem(LS_GROUP);
2642
2798
  if (["none", "app", "org"].includes(v)) _tblGroup = v;
2643
2799
  } catch {}
2644
- // The Last run column follows the dock.
2645
- window.denShell?.runs?.onChange?.(paintLastRuns);
2800
+ // The Last test run column: the server's history, read now, when a
2801
+ // run here changes, and every 30 s for runs from elsewhere.
2802
+ void loadLastRuns();
2803
+ // The dock changes on every line of a run: read at most every 2 s.
2804
+ let _lastRunsSoon = null;
2805
+ window.denShell?.runs?.onChange?.(() => {
2806
+ _lastRunsSoon ??= setTimeout(() => {
2807
+ _lastRunsSoon = null;
2808
+ void loadLastRuns();
2809
+ }, 2_000);
2810
+ });
2811
+ setInterval(() => {
2812
+ if (!document.hidden) void loadLastRuns();
2813
+ }, 30_000);
2646
2814
 
2647
2815
  // ── Tab management (one persistent tab per service address) ───────────────
2648
2816
  //
@@ -4110,7 +4278,11 @@
4110
4278
  const params = new URLSearchParams(location.search);
4111
4279
  params.delete("ws");
4112
4280
  const qs = params.toString();
4113
- history.replaceState(null, "", qs ? "?" + qs : location.pathname);
4281
+ history.replaceState(
4282
+ null,
4283
+ "",
4284
+ (qs ? "?" + qs : location.pathname) + location.hash,
4285
+ );
4114
4286
  }
4115
4287
  await loadWorkspaces();
4116
4288
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Where a service's live log comes from (0.4.18): one rule for the Logs
3
+ * page and the service panel's Logs, so they never disagree.
4
+ *
5
+ * - Den's own server log (the "__DEN_CLI__/server" pseudo-service):
6
+ * /api/server-log, the server's log ring;
7
+ * - a local service: /api/service/taillog, the workspace page's source
8
+ * chain (the manifest's command, the conventional container, the
9
+ * daemon's startup log). /api/service/logs answers 404 for a service
10
+ * with no `logs:` block;
11
+ * - any other env: /api/service/logs, for cloud sources and the reauth
12
+ * flow.
13
+ */
14
+
15
+ /** The server's own log, shown like a service's. */
16
+ export function isServerLog(repo, service) {
17
+ return repo === "__DEN_CLI__" && service === "server";
18
+ }
19
+
20
+ /** The SSE URL of `repo/service`'s live log in `env`. */
21
+ export function logStreamUrl(repo, service, env) {
22
+ if (isServerLog(repo, service)) return "/api/server-log";
23
+ if (env === "local")
24
+ return `/api/service/taillog?${new URLSearchParams({ repo, service })}`;
25
+ return `/api/service/logs?${new URLSearchParams({ repo, service, env })}`;
26
+ }
@@ -476,6 +476,7 @@
476
476
  envReady,
477
477
  initWorkspacePill,
478
478
  } from "/header.js";
479
+ import { isServerLog, logStreamUrl } from "/log-source.js";
479
480
 
480
481
  // ── Safe highlight rendering ──────────────────────────────────────────────
481
482
  // Log lines are UNTRUSTED (any service can log attacker-influenced text,
@@ -550,11 +551,9 @@
550
551
  const logStreams = new Map();
551
552
 
552
553
  // "__DEN_CLI__/server" is a pseudo-service that taps the server's own log ring
553
- // via /api/server-log instead of /api/service/logs. Everything else in the
554
- // page treats it like a normal log stream.
555
- function isServerLog(repo, service) {
556
- return repo === "__DEN_CLI__" && service === "server";
557
- }
554
+ // via /api/server-log instead of /api/service/logs (isServerLog,
555
+ // log-source.js). Everything else in the page treats it like a normal
556
+ // log stream.
558
557
  function formatServerLogEntry(entry) {
559
558
  // "14:07:41 [lifecycle] ▶ run synthetic/local · modules=all [abcxyz]"
560
559
  // An agent's action reads "[agent]" and ends "· by agent <client> …".
@@ -1075,21 +1074,9 @@
1075
1074
  // ─────────────────────────────────────────────────────────────────────
1076
1075
 
1077
1076
  const serverLog = isServerLog(repo, service);
1078
- // Local streams go through taillog — the same endpoint the workspace
1079
- // page's tabs use. It has the full source-resolution chain (manifest
1080
- // cmd → conventional opctl container → daemon startup log), whereas
1081
- // /api/service/logs 404s for services with no logs block (shop-web,
1082
- // control-plane). Non-local envs keep /api/service/logs for
1083
- // cloudwatch sources + the AWS reauth flow.
1084
- const es = serverLog
1085
- ? new EventSource("/api/server-log")
1086
- : env === "local"
1087
- ? new EventSource(
1088
- `/api/service/taillog?${new URLSearchParams({ repo, service })}`,
1089
- )
1090
- : new EventSource(
1091
- `/api/service/logs?${new URLSearchParams({ repo, service, env })}`,
1092
- );
1077
+ // Where the stream comes from (taillog for local, logs elsewhere):
1078
+ // log-source.js, which the service panel's Logs uses too.
1079
+ const es = new EventSource(logStreamUrl(repo, service, env));
1093
1080
 
1094
1081
  es.onmessage = (e) => {
1095
1082
  const msg = JSON.parse(e.data);
@@ -79,7 +79,7 @@ export const ACTIONS = [
79
79
  },
80
80
  {
81
81
  id: "open",
82
- label: "Open ↗",
82
+ label: "Visit ↗",
83
83
  for: ["service"],
84
84
  tier: "common",
85
85
  when: (s) => !!(s.open || s.target?.url),
@@ -134,6 +134,10 @@
134
134
  font-size: 11px;
135
135
  color: var(--muted);
136
136
  }
137
+ .view-note {
138
+ color: var(--muted);
139
+ font-size: 12px;
140
+ }
137
141
  #status-text {
138
142
  font-size: 11px;
139
143
  color: var(--muted);
@@ -678,6 +682,7 @@
678
682
  ⟳ Refresh
679
683
  </button>
680
684
  <span id="status-text">idle</span>
685
+ <span id="view-note"></span>
681
686
  <div class="h-spacer" style="flex: 1"></div>
682
687
  <button class="btn" id="waves-btn" title="Edit wave assignment">
683
688
  Waves…
@@ -735,6 +740,7 @@
735
740
  refreshHealth,
736
741
  initWorkspacePill,
737
742
  } from "/header.js";
743
+ import { viewNoteHtml, viewRepos, viewScope } from "/view-scope.js";
738
744
 
739
745
  // The header's dots report LOCAL service health here: #den-shell pins
740
746
  // data-health-env="local" (shell.js). The env picker is the shell's
@@ -819,6 +825,15 @@
819
825
  .replace(/>/g, "&gt;")
820
826
  .replace(/"/g, "&quot;");
821
827
 
828
+ // 0.4.17: the repos follow the top bar's View (Org and Workspace),
829
+ // as on Environments: no picker of this page's own (view-scope.js).
830
+ let viewIn = null;
831
+ const viewReady = viewScope().then(async (v) => {
832
+ const note = document.getElementById("view-note");
833
+ if (note) note.innerHTML = viewNoteHtml(v);
834
+ viewIn = await viewRepos(v);
835
+ });
836
+
822
837
  // ── Init ──────────────────────────────────────────────────────────────────
823
838
  async function init() {
824
839
  try {
@@ -909,7 +924,10 @@
909
924
  const data = await fetch(
910
925
  `/api/release/status?env=${encodeURIComponent(state.env)}&refresh=${refresh ? 1 : 0}`,
911
926
  ).then((r) => r.json());
912
- state.rows = data.rows || [];
927
+ await viewReady;
928
+ state.rows = (data.rows || []).filter(
929
+ (r) => !viewIn || viewIn.has(r.repo),
930
+ );
913
931
  setStatus(
914
932
  data.error
915
933
  ? `error: ${data.error}`
@@ -211,6 +211,20 @@
211
211
  font-weight: 600;
212
212
  color: var(--text);
213
213
  }
214
+ /* 0.4.18: the name opens the service panel (svc-panel.js). */
215
+ .svc-name-btn {
216
+ background: none;
217
+ border: 0;
218
+ padding: 0;
219
+ font: inherit;
220
+ color: inherit;
221
+ cursor: pointer;
222
+ text-align: left;
223
+ }
224
+ .svc-name-btn:hover,
225
+ .svc-name-btn[aria-expanded="true"] {
226
+ text-decoration: underline;
227
+ }
214
228
  .service-repo .owners {
215
229
  color: var(--accent-text);
216
230
  }
@@ -335,6 +349,7 @@
335
349
  envReady,
336
350
  sudoRun,
337
351
  } from "/header.js";
352
+ import { logStreamUrl } from "/log-source.js";
338
353
  import { ownersLabel } from "/workspace-picker.js";
339
354
 
340
355
  let es = null;
@@ -656,7 +671,7 @@
656
671
  <div class="service-card-header">
657
672
  <span class="dot" id="dot-${s.repo}-${s.service}"></span>
658
673
  <div class="service-card-title">
659
- <div class="service-name">${addr}</div>
674
+ <div class="service-name"><button type="button" class="svc-name-btn" data-svpanel-open="${esc(isSub ? addr : s.repo)}" aria-haspopup="dialog" aria-expanded="false" title="${esc(window.denShell?.t?.("svp.more", { s: isSub ? addr : s.repo }) ?? addr)}">${esc(addr)}</button></div>
660
675
  <div class="service-repo">${s.repo}${
661
676
  ownersLabel(s.inWorkspaces)
662
677
  ? `<span class="owners" title="Shared: listed once for every workspace in view that defines it">${esc(ownersLabel(s.inWorkspaces))}</span>`
@@ -821,8 +836,8 @@
821
836
  );
822
837
  appendLine(`\n▶ ${repo}/${service} logs · ${env}\n`, "cmd");
823
838
 
824
- const params = new URLSearchParams({ repo, service, env });
825
- es = new EventSource(`/api/service/logs?${params}`);
839
+ // The Logs page's source (log-source.js): taillog for local.
840
+ es = new EventSource(logStreamUrl(repo, service, env));
826
841
  handleSSE(es, repo, service, { stream: true });
827
842
  }
828
843