@bongos/core 1.19.457 → 1.19.458
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.
- package/.bongos-core.json +40 -30
- package/docs/copy-inventory.md +160 -177
- package/docs/copy-registry.json +445 -606
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/gate.css +20 -88
- package/modules/hall-ui/public/gate.html +24 -12
- package/modules/hall-ui/public/gate.js +77 -60
- package/modules/hall-ui/public/gate.states.json +19 -1
- package/modules/hall-ui/public/government.css +96 -154
- package/modules/hall-ui/public/government.html +80 -52
- package/modules/hall-ui/public/government.js +227 -136
- package/modules/hall-ui/public/government.states.json +71 -1
- package/modules/hall-ui/public/harbor.css +9 -120
- package/modules/hall-ui/public/harbor.html +23 -11
- package/modules/hall-ui/public/harbor.js +109 -83
- package/modules/hall-ui/public/harbor.states.json +19 -1
- package/modules/hall-ui/public/oversight.css +296 -0
- package/modules/hall-ui/public/watch.css +56 -170
- package/modules/hall-ui/public/watch.html +48 -37
- package/modules/hall-ui/public/watch.js +363 -298
- package/modules/hall-ui/public/watch.states.json +48 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_oversight_world.mjs +118 -0
|
@@ -45,6 +45,75 @@
|
|
|
45
45
|
},
|
|
46
46
|
"out": {
|
|
47
47
|
"auth": false
|
|
48
|
+
},
|
|
49
|
+
"permissions": {
|
|
50
|
+
"auth": true,
|
|
51
|
+
"actions": [
|
|
52
|
+
[
|
|
53
|
+
"wait",
|
|
54
|
+
600
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
"click",
|
|
58
|
+
"#tab-ranks"
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
"wait",
|
|
62
|
+
300
|
|
63
|
+
]
|
|
64
|
+
],
|
|
65
|
+
"expect": {
|
|
66
|
+
"visible": [
|
|
67
|
+
"#gov-panel-ranks",
|
|
68
|
+
"#gov-rank-list"
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"board": {
|
|
73
|
+
"auth": true,
|
|
74
|
+
"actions": [
|
|
75
|
+
[
|
|
76
|
+
"wait",
|
|
77
|
+
600
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
"click",
|
|
81
|
+
"#tab-board-room"
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
"wait",
|
|
85
|
+
300
|
|
86
|
+
]
|
|
87
|
+
],
|
|
88
|
+
"expect": {
|
|
89
|
+
"visible": [
|
|
90
|
+
"#gov-panel-board",
|
|
91
|
+
"#gov-board"
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"constitution-tab": {
|
|
96
|
+
"auth": true,
|
|
97
|
+
"actions": [
|
|
98
|
+
[
|
|
99
|
+
"wait",
|
|
100
|
+
600
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
"click",
|
|
104
|
+
"#tab-constitution"
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
"wait",
|
|
108
|
+
300
|
|
109
|
+
]
|
|
110
|
+
],
|
|
111
|
+
"expect": {
|
|
112
|
+
"visible": [
|
|
113
|
+
"#gov-panel-constitution",
|
|
114
|
+
"#gov-constitution-live"
|
|
115
|
+
]
|
|
116
|
+
}
|
|
48
117
|
}
|
|
49
118
|
},
|
|
50
119
|
"reducedMotion": {
|
|
@@ -55,5 +124,6 @@
|
|
|
55
124
|
600
|
|
56
125
|
]
|
|
57
126
|
]
|
|
58
|
-
}
|
|
127
|
+
},
|
|
128
|
+
"_states": "task 1003469: the three tabs are three states, so the Permissions two-panel layout, the Board Room sittings and the Constitution panels are each measured. `constitution` keeps its name for the report history; against --rank archon it lands on the Permissions tab (the Archon's working surface), so `permissions` is the same landing named honestly and `board` clicks through."
|
|
59
129
|
}
|
|
@@ -1,120 +1,9 @@
|
|
|
1
|
-
/* harbor.css — Page-local styles for the Harbor
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
.harbor-
|
|
10
|
-
display: grid;
|
|
11
|
-
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
12
|
-
gap: 14px;
|
|
13
|
-
margin: 6px 0 22px;
|
|
14
|
-
}
|
|
15
|
-
.harbor-stat {
|
|
16
|
-
border: var(--line) solid var(--rule-soft);
|
|
17
|
-
border-radius: var(--radius);
|
|
18
|
-
background: var(--bg-card);
|
|
19
|
-
padding: 12px 14px;
|
|
20
|
-
}
|
|
21
|
-
.harbor-stat__label {
|
|
22
|
-
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
|
|
23
|
-
font-weight: 600; color: var(--ink-faint);
|
|
24
|
-
}
|
|
25
|
-
.harbor-stat__value {
|
|
26
|
-
font-size: 22px; font-weight: 650; color: var(--ink);
|
|
27
|
-
font-variant-numeric: tabular-nums; margin-top: 2px;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/* The dock — the strip the box cards sit on. */
|
|
31
|
-
.harbor-dock {
|
|
32
|
-
display: grid;
|
|
33
|
-
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
34
|
-
gap: 16px;
|
|
35
|
-
padding: 18px;
|
|
36
|
-
border-radius: var(--radius);
|
|
37
|
-
border: var(--line) solid var(--rule-soft);
|
|
38
|
-
background: var(--glass);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
.harbor-container {
|
|
42
|
-
border: var(--line) solid var(--rule-soft);
|
|
43
|
-
border-radius: var(--radius);
|
|
44
|
-
background: var(--bg-card);
|
|
45
|
-
overflow: hidden;
|
|
46
|
-
display: flex; flex-direction: column;
|
|
47
|
-
}
|
|
48
|
-
/* The state strip along the card's top, coloured by lifecycle state. */
|
|
49
|
-
.harbor-container__roof {
|
|
50
|
-
height: 4px;
|
|
51
|
-
background-color: var(--ok);
|
|
52
|
-
}
|
|
53
|
-
.harbor-container--parked .harbor-container__roof { background-color: var(--ink-faint); }
|
|
54
|
-
.harbor-container--pending .harbor-container__roof { background-color: var(--warn); }
|
|
55
|
-
.harbor-container--gone .harbor-container__roof { background-color: var(--rule); }
|
|
56
|
-
.harbor-container--error .harbor-container__roof { background-color: var(--danger); }
|
|
57
|
-
.harbor-container--blocked .harbor-container__roof { background-color: var(--danger); }
|
|
58
|
-
|
|
59
|
-
.harbor-container__body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
|
60
|
-
.harbor-container__head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
|
61
|
-
.harbor-container__who { color: var(--ink); font-weight: 600; font-size: 14.5px; }
|
|
62
|
-
.harbor-container__rank { color: var(--ink-faint); font-size: 12px; margin-left: 6px; font-weight: 400; }
|
|
63
|
-
|
|
64
|
-
/* Base: the shell's .fact-pill (task 1003307). States are hairlines, not fills. */
|
|
65
|
-
.harbor-pill--active { color: var(--ok); }
|
|
66
|
-
.harbor-pill--pending { color: var(--warn); }
|
|
67
|
-
.harbor-pill--blocked { color: var(--danger); }
|
|
68
|
-
|
|
69
|
-
.harbor-rows { list-style: none; padding: 0; margin: 2px 0 0; }
|
|
70
|
-
.harbor-rows li {
|
|
71
|
-
display: flex; justify-content: space-between; gap: 12px;
|
|
72
|
-
padding: 4px 0; border-bottom: var(--line) solid var(--rule-soft);
|
|
73
|
-
font-size: 13px;
|
|
74
|
-
}
|
|
75
|
-
.harbor-rows li:last-child { border-bottom: none; }
|
|
76
|
-
.harbor-rows li .k { color: var(--ink-soft); }
|
|
77
|
-
.harbor-rows li .v { color: var(--ink); font-variant-numeric: tabular-nums; text-align: right; }
|
|
78
|
-
|
|
79
|
-
.harbor-blocked-note {
|
|
80
|
-
font-size: 12.5px; color: var(--danger);
|
|
81
|
-
border-left: 2px solid var(--danger); padding-left: 8px; margin: 2px 0 0;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.harbor-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: auto; padding-top: 8px; }
|
|
85
|
-
.harbor-btn {
|
|
86
|
-
font: inherit; font-size: 12.5px; font-weight: 500;
|
|
87
|
-
padding: 5px 12px; border: var(--line) solid var(--rule);
|
|
88
|
-
background: var(--bg-card); color: var(--ink-soft);
|
|
89
|
-
border-radius: var(--radius-sm); cursor: pointer; flex: 1;
|
|
90
|
-
}
|
|
91
|
-
.harbor-btn:hover:not(:disabled) { background: var(--glass); color: var(--ink); }
|
|
92
|
-
.harbor-btn:disabled { opacity: 0.45; cursor: default; }
|
|
93
|
-
.harbor-btn--danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
|
|
94
|
-
|
|
95
|
-
.harbor-empty, .harbor-sealed { color: var(--ink-soft); padding: 16px 0; }
|
|
96
|
-
|
|
97
|
-
/* Lightweight modal for confirm / block dialogs. Viewport-fixed overlay on
|
|
98
|
-
the shell's own --z-modal layer — the bare 200 that used to be here was a
|
|
99
|
-
guess at "above the drawer", and gate.css guessed 50 for the same component
|
|
100
|
-
(task 1003307). */
|
|
101
|
-
.harbor-modal-bg {
|
|
102
|
-
position: fixed; inset: 0; background: color-mix(in srgb, var(--ink) 45%, transparent);
|
|
103
|
-
display: flex; align-items: center; justify-content: center; z-index: var(--z-modal); padding: 20px;
|
|
104
|
-
}
|
|
105
|
-
.harbor-modal {
|
|
106
|
-
background: var(--bg-card); border: var(--line) solid var(--rule-soft); border-radius: var(--radius);
|
|
107
|
-
max-width: 460px; width: 100%; padding: 20px 22px;
|
|
108
|
-
box-shadow: var(--shadow-pop);
|
|
109
|
-
}
|
|
110
|
-
.harbor-modal h2 { color: var(--ink); margin: 0 0 8px; font-size: 16px; font-weight: 650; }
|
|
111
|
-
.harbor-modal p { color: var(--ink); font-size: 13.5px; margin: 0 0 12px; }
|
|
112
|
-
.harbor-modal label { display: block; font-size: 13px; color: var(--ink-soft); margin: 10px 0 4px; }
|
|
113
|
-
.harbor-modal input[type=text], .harbor-modal textarea {
|
|
114
|
-
width: 100%; box-sizing: border-box; font: inherit; font-size: 13.5px;
|
|
115
|
-
padding: 7px 9px; border: var(--line) solid var(--rule); border-radius: var(--radius-sm);
|
|
116
|
-
background: var(--bg-card); color: var(--ink);
|
|
117
|
-
}
|
|
118
|
-
.harbor-modal__check { display: flex; align-items: flex-start; gap: 8px; margin: 12px 0 0; font-size: 13px; color: var(--ink); }
|
|
119
|
-
.harbor-modal__check input { margin-top: 3px; }
|
|
120
|
-
.harbor-modal__actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
|
|
1
|
+
/* harbor.css — Page-local styles for the Harbor (task 1003469 rebuild).
|
|
2
|
+
The harbor is one panel of oversight rows grouped by lifecycle, over the
|
|
3
|
+
shell's glass summary strip; everything structural is oversight.css. What is
|
|
4
|
+
the harbor's own is the one line a barred builder carries. The ?v= cache-bust
|
|
5
|
+
is stamped from this file's content hash at boot (src/asset-version.js). */
|
|
6
|
+
|
|
7
|
+
/* A barred builder: the reason under the row's title, in the danger ink and at
|
|
8
|
+
the body measure. Not a coloured bar — the world marks state with a pill. */
|
|
9
|
+
.harbor-barred { margin: 6px 0 0; font-size: 13px; line-height: 1.5; color: var(--danger); max-width: var(--measure); overflow-wrap: anywhere; }
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
10
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
11
|
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
12
|
+
<link rel="stylesheet" href="/builders/oversight.css">
|
|
12
13
|
<link rel="stylesheet" href="/builders/harbor.css">
|
|
13
14
|
</head>
|
|
14
15
|
<body data-page="harbor" data-page-title="Harbor">
|
|
@@ -19,24 +20,34 @@
|
|
|
19
20
|
<div class="app-body">
|
|
20
21
|
<header class="app-topbar" id="app-topbar"></header>
|
|
21
22
|
<div class="app-content">
|
|
22
|
-
<!--
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
is the LABEL voice, not a display line: on a working surface the
|
|
26
|
-
page's own name is the least interesting thing on it. -->
|
|
23
|
+
<!-- The board page head (task 1003307): the LABEL voice, not a display
|
|
24
|
+
line, because on a working surface the page's own name is the least
|
|
25
|
+
interesting thing on it. -->
|
|
27
26
|
<div class="page-head page-head--board">
|
|
28
27
|
<h1 class="page-head__h">Harbor</h1>
|
|
29
28
|
<p class="page-head__sub" id="harbor-sub">Loading dev boxes…</p>
|
|
30
29
|
</div>
|
|
31
30
|
|
|
32
31
|
<main id="hall-main">
|
|
33
|
-
|
|
32
|
+
<!-- task 1003469: the sealed state is a panel with a heading, not a bare
|
|
33
|
+
line on the ground. harbor.js writes the real floor into the lede
|
|
34
|
+
from the pack's rank label, so the copy cannot drift from ADR 0157. -->
|
|
35
|
+
<section class="scroll" id="harbor-sealed" aria-labelledby="harbor-sealed-h" hidden>
|
|
36
|
+
<h2 class="scroll__h" id="harbor-sealed-h">Restricted</h2>
|
|
37
|
+
<p class="scroll__lede" id="harbor-sealed-body">This page opens at a higher rank.</p>
|
|
38
|
+
</section>
|
|
34
39
|
|
|
35
40
|
<section id="harbor-body" hidden>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
<!-- The glass summary strip (the shell's .stat-row), then ONE panel of
|
|
42
|
+
oversight rows grouped by lifecycle — the 280px card deck with a
|
|
43
|
+
coloured roof band per box is gone (task 1003469). -->
|
|
44
|
+
<div class="stat-row ov-summary" id="harbor-summary" aria-label="Fleet summary"></div>
|
|
45
|
+
|
|
46
|
+
<section class="scroll" aria-labelledby="harbor-dock-h">
|
|
47
|
+
<h2 class="scroll__h" id="harbor-dock-h">Dev boxes</h2>
|
|
48
|
+
<p class="scroll__lede">Every builder's box, running ones first. Close a box to reclaim it; block a builder to keep them from opening another.</p>
|
|
49
|
+
<div id="harbor-dock"><span class="kit-skel"></span></div>
|
|
50
|
+
</section>
|
|
40
51
|
</section>
|
|
41
52
|
</main>
|
|
42
53
|
|
|
@@ -52,6 +63,7 @@
|
|
|
52
63
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
53
64
|
<script src="/builders/palette.js" defer></script>
|
|
54
65
|
<script src="/builders/dom-utils.js?v=2026-06-16-harbor"></script>
|
|
55
|
-
<script src="/builders/
|
|
66
|
+
<script src="/builders/hall-kit.js"></script>
|
|
67
|
+
<script src="/builders/harbor.js?v=2026-09-02-harbor-rows"></script>
|
|
56
68
|
</body>
|
|
57
69
|
</html>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// public-builders/harbor.js — the Harbor (/builders/harbor).
|
|
2
2
|
//
|
|
3
3
|
// Metic+ dev-box management view (idea 257 / task 1163; Archon-only until ADR 0157
|
|
4
|
-
// moved `box.fleet.manage` to the working rank). Every builder's dev box is
|
|
5
|
-
//
|
|
6
|
-
// opening new ones.
|
|
4
|
+
// moved `box.fleet.manage` to the working rank). Every builder's dev box is one
|
|
5
|
+
// oversight row in a panel, grouped by lifecycle (task 1003469); a Metic or Archon
|
|
6
|
+
// can close any box and block a builder from opening new ones.
|
|
7
7
|
//
|
|
8
8
|
// Like the Watch page, this reads /api/bongos/me to learn the viewer's rank and
|
|
9
9
|
// shows a restricted notice below the Metic floor — DEFENCE IN DEPTH only. The data +
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
|
|
22
22
|
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
23
23
|
const { escapeHtml, fmtInt } = window.OTB;
|
|
24
|
+
const { emptyStateHtml } = window.OTBKit;
|
|
24
25
|
const rankLabel = (rank) => window.OTB.rankLabel(rank, { empty: '' });
|
|
25
26
|
const toast = (msg) => window.OTB.toast(msg);
|
|
26
27
|
|
|
@@ -69,18 +70,16 @@
|
|
|
69
70
|
return `$${v.toFixed(2)}`;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
default: return { mod: 'gone', pill: state || 'none', pillMod: '' };
|
|
83
|
-
}
|
|
73
|
+
// The lifecycle state as a hairline pill in the state's colour — the shell's
|
|
74
|
+
// .fact-pill states, never a coloured band on the row (task 1003469 retired the
|
|
75
|
+
// 4px "roof" strip each card wore).
|
|
76
|
+
function statePill(state) {
|
|
77
|
+
const s = state || 'none';
|
|
78
|
+
const mod = s === 'active' ? 'ok'
|
|
79
|
+
: (s === 'provisioning' || s === 'waking' || s === 'parking') ? 'warn'
|
|
80
|
+
: s === 'error' ? 'danger'
|
|
81
|
+
: 'quiet';
|
|
82
|
+
return `<span class="fact-pill fact-pill--${mod}">${escapeHtml(s)}</span>`;
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
// ---- modal ---------------------------------------------------------------
|
|
@@ -90,26 +89,30 @@
|
|
|
90
89
|
if (root) root.innerHTML = '';
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
// Build
|
|
94
|
-
//
|
|
95
|
-
|
|
92
|
+
// Build the confirm dialog on the shell's --z-modal layer: the oversight
|
|
93
|
+
// family's .ov-modal (oversight.css). `bodyHtml` is optional extra HTML
|
|
94
|
+
// (inputs); onConfirm receives the dialog so the caller can read them.
|
|
95
|
+
function openModal({ title, bodyHtml, confirmLabel, onConfirm }) {
|
|
96
96
|
const root = document.getElementById('harbor-modal-root');
|
|
97
97
|
root.innerHTML = '';
|
|
98
98
|
const bg = document.createElement('div');
|
|
99
|
-
bg.className = '
|
|
99
|
+
bg.className = 'ov-modal-bg';
|
|
100
100
|
bg.innerHTML =
|
|
101
|
-
`<div class="
|
|
102
|
-
<h2>${escapeHtml(title)}</h2>
|
|
101
|
+
`<div class="ov-modal" role="dialog" aria-modal="true" aria-labelledby="harbor-modal-h">
|
|
102
|
+
<h2 class="scroll__h" id="harbor-modal-h">${escapeHtml(title)}</h2>
|
|
103
103
|
${bodyHtml}
|
|
104
|
-
<div class="
|
|
105
|
-
<button class="
|
|
106
|
-
<button class="
|
|
104
|
+
<div class="ov-modal__actions">
|
|
105
|
+
<button type="button" class="btn-ghost" data-act="cancel">Cancel</button>
|
|
106
|
+
<button type="button" class="btn-accent" data-act="confirm">${escapeHtml(confirmLabel)}</button>
|
|
107
107
|
</div>
|
|
108
108
|
</div>`;
|
|
109
109
|
bg.addEventListener('click', (e) => { if (e.target === bg) closeModal(); });
|
|
110
|
+
bg.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
|
110
111
|
bg.querySelector('[data-act=cancel]').addEventListener('click', closeModal);
|
|
111
112
|
bg.querySelector('[data-act=confirm]').addEventListener('click', () => onConfirm(bg));
|
|
112
113
|
root.appendChild(bg);
|
|
114
|
+
const first = bg.querySelector('textarea, input, [data-act=confirm]');
|
|
115
|
+
if (first) first.focus();
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
// ---- actions -------------------------------------------------------------
|
|
@@ -122,7 +125,6 @@
|
|
|
122
125
|
bodyHtml: `<p>This will ${verb} <strong>${escapeHtml(who)}</strong>'s dev box (state: ${escapeHtml(box.state)}). Any uncommitted work on that box is lost. The box is destroyed; the builder can open a fresh one unless they are also blocked.</p>
|
|
123
126
|
<p>A fresh box has a <strong>new SSH identity</strong>. If their Claude Desktop later says “Host denied” or asks for a password, tell them to fully restart Desktop (it re-pins) or press Repair — not to keep rebuilding, which only rotates the identity again.</p>`,
|
|
124
127
|
confirmLabel: 'Close box',
|
|
125
|
-
danger: true,
|
|
126
128
|
onConfirm: async () => {
|
|
127
129
|
try {
|
|
128
130
|
const r = await sendJson('POST', `/boxes/${box.builder.id}/close`, {});
|
|
@@ -146,9 +148,8 @@
|
|
|
146
148
|
`<p>${escapeHtml(who)} will not be able to open or wake a dev box until you unblock them. A box they already have running is left alone unless you also close it below.</p>
|
|
147
149
|
<label for="harbor-block-reason">Reason (shown to the builder)</label>
|
|
148
150
|
<textarea id="harbor-block-reason" rows="2" placeholder="e.g. runaway cost — paused pending review"></textarea>
|
|
149
|
-
${hasLiveBox ? `<label class="
|
|
151
|
+
${hasLiveBox ? `<label class="ov-modal__check"><input type="checkbox" id="harbor-block-close"> Also close the box they have running now (state: ${escapeHtml(box.state)})</label>` : ''}`,
|
|
150
152
|
confirmLabel: 'Block builder',
|
|
151
|
-
danger: true,
|
|
152
153
|
onConfirm: async (modal) => {
|
|
153
154
|
const reason = (modal.querySelector('#harbor-block-reason') || {}).value || '';
|
|
154
155
|
const closeEl = modal.querySelector('#harbor-block-close');
|
|
@@ -175,7 +176,6 @@
|
|
|
175
176
|
title: `Lift the block on ${who}?`,
|
|
176
177
|
bodyHtml: `<p>${escapeHtml(who)} will be able to open dev boxes again.</p>`,
|
|
177
178
|
confirmLabel: 'Unblock',
|
|
178
|
-
danger: false,
|
|
179
179
|
onConfirm: async () => {
|
|
180
180
|
try {
|
|
181
181
|
await sendJson('PATCH', `/boxes/${box.builder.id}/block`, { blocked: false });
|
|
@@ -192,56 +192,56 @@
|
|
|
192
192
|
|
|
193
193
|
// ---- render --------------------------------------------------------------
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
195
|
+
// One box, one oversight row (oversight.css .ov-row): who and their rank, the
|
|
196
|
+
// state pill, then the facts a fleet operator scans for — make, address, how
|
|
197
|
+
// long it has been up, how long idle, what it has cost — the barred note when
|
|
198
|
+
// there is one, and the two actions on the right.
|
|
199
|
+
function renderRow(box, costByBuilder) {
|
|
200
|
+
const blocked = !!(box.blocked && box.blocked.is);
|
|
201
|
+
const who = escapeHtml(box.builder.display_name || box.builder.github_login || `#${box.builder.id}`);
|
|
202
|
+
const login = box.builder.github_login ? `<span class="ov-mono">@${escapeHtml(box.builder.github_login)}</span>` : '';
|
|
199
203
|
const rank = rankLabel(box.builder.rank);
|
|
200
204
|
const cost = costByBuilder.get(String(box.builder.id));
|
|
201
205
|
|
|
202
|
-
const
|
|
206
|
+
const facts = [statePill(box.state)];
|
|
207
|
+
if (blocked) facts.push('<span class="fact-pill fact-pill--danger">blocked</span>');
|
|
208
|
+
if (rank) facts.push(`<span class="fact-chip">${escapeHtml(rank)}</span>`);
|
|
203
209
|
if (box.region || box.size_slug) {
|
|
204
|
-
|
|
210
|
+
facts.push(`<span class="ov-fact">${escapeHtml([box.region, box.size_slug].filter(Boolean).join(' · '))}</span>`);
|
|
205
211
|
}
|
|
206
|
-
if (box.ip && box.state === 'active')
|
|
212
|
+
if (box.ip && box.state === 'active') facts.push(`<span class="ov-mono">${escapeHtml(box.ip)}</span>`);
|
|
207
213
|
if (box.state === 'active') {
|
|
208
|
-
|
|
209
|
-
|
|
214
|
+
facts.push(`<span class="ov-fact">up <b>${fmtAgo(box.provisioned_at)}</b></span>`);
|
|
215
|
+
facts.push(`<span class="ov-fact">idle <b>${fmtAgo(box.last_activity_at)}</b>${box.claude_active ? ' · claude live' : ''}</span>`);
|
|
210
216
|
} else if (box.state === 'parked') {
|
|
211
|
-
|
|
212
|
-
} else if (CLOSEABLE.has(box.state)) {
|
|
213
|
-
|
|
217
|
+
facts.push(`<span class="ov-fact">parked <b>${fmtAgo(box.parked_at)}</b> ago</span>`);
|
|
218
|
+
} else if (CLOSEABLE.has(box.state) || box.state === 'provisioning') {
|
|
219
|
+
facts.push(`<span class="ov-fact">in this state <b>${fmtAgo(box.last_activity_at || box.provisioned_at)}</b></span>`);
|
|
214
220
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const rowsHtml = rows.map(([k, v]) =>
|
|
218
|
-
`<li><span class="k">${escapeHtml(k)}</span><span class="v">${v}</span></li>`).join('');
|
|
221
|
+
facts.push(`<span class="ov-fact">cost <b>${cost != null ? fmtUsd(cost) : fmtUsd(box.compute_cost_usd)}</b></span>`);
|
|
219
222
|
|
|
220
|
-
const
|
|
221
|
-
? `<p class="harbor-
|
|
223
|
+
const barred = blocked
|
|
224
|
+
? `<p class="harbor-barred">Barred from opening boxes${box.blocked.reason ? `: ${escapeHtml(box.blocked.reason)}` : '.'}</p>`
|
|
222
225
|
: '';
|
|
223
226
|
|
|
224
227
|
const closeable = CLOSEABLE.has(box.state);
|
|
225
|
-
const closeLabel = box.state === 'parked' ? 'Destroy' : 'Close';
|
|
226
228
|
const actions = [];
|
|
227
|
-
if (closeable)
|
|
229
|
+
if (closeable) {
|
|
230
|
+
actions.push(`<button type="button" class="btn-ghost btn-ghost--danger" data-act="close">${box.state === 'parked' ? 'Destroy' : 'Close'}</button>`);
|
|
231
|
+
}
|
|
228
232
|
actions.push(blocked
|
|
229
|
-
?
|
|
230
|
-
:
|
|
233
|
+
? '<button type="button" class="btn-ghost" data-act="unblock">Unblock</button>'
|
|
234
|
+
: '<button type="button" class="btn-ghost" data-act="block">Block</button>');
|
|
231
235
|
|
|
232
|
-
const el = document.createElement('
|
|
233
|
-
el.className =
|
|
236
|
+
const el = document.createElement('li');
|
|
237
|
+
el.className = 'ov-row';
|
|
234
238
|
el.innerHTML =
|
|
235
|
-
`<div class="
|
|
236
|
-
|
|
237
|
-
<div class="
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
<ul class="harbor-rows">${rowsHtml}</ul>
|
|
242
|
-
${blockedNote}
|
|
243
|
-
<div class="harbor-actions">${actions.join('')}</div>
|
|
244
|
-
</div>`;
|
|
239
|
+
`<div class="ov-row__main">
|
|
240
|
+
<span class="ov-row__title">${who}${login}</span>
|
|
241
|
+
<div class="ov-row__facts">${facts.join('')}</div>
|
|
242
|
+
${barred}
|
|
243
|
+
</div>
|
|
244
|
+
<div class="ov-row__right"><div class="ov-row__actions">${actions.join('')}</div></div>`;
|
|
245
245
|
|
|
246
246
|
const closeBtn = el.querySelector('[data-act=close]');
|
|
247
247
|
if (closeBtn) closeBtn.addEventListener('click', () => confirmClose(box));
|
|
@@ -252,26 +252,50 @@
|
|
|
252
252
|
return el;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
// The fleet reads in three runs — running, parked or in transition, no box —
|
|
256
|
+
// so the operator's eye lands on the boxes that cost money first. Within a run
|
|
257
|
+
// the API's order holds (most recently touched first). An empty run is not
|
|
258
|
+
// drawn: a head over nothing is a promise the page cannot keep.
|
|
259
|
+
const GROUPS = [
|
|
260
|
+
{ name: 'Running', test: (b) => b.state === 'active' },
|
|
261
|
+
{ name: 'Parked or in transition', test: (b) => CLOSEABLE.has(b.state) || b.state === 'provisioning' },
|
|
262
|
+
{ name: 'No box', test: () => true },
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
function renderDock(boxes, costByBuilder) {
|
|
266
|
+
const dock = document.getElementById('harbor-dock');
|
|
267
|
+
dock.innerHTML = '';
|
|
268
|
+
let left = boxes.slice();
|
|
269
|
+
for (const g of GROUPS) {
|
|
270
|
+
const mine = left.filter(g.test);
|
|
271
|
+
left = left.filter((b) => !g.test(b));
|
|
272
|
+
if (!mine.length) continue;
|
|
273
|
+
const sec = document.createElement('section');
|
|
274
|
+
sec.className = 'ov-group';
|
|
275
|
+
sec.innerHTML = `<div class="ov-group__head"><span class="ov-group__name">${escapeHtml(g.name)}</span><span class="ov-group__n">${fmtInt(mine.length)}</span></div>`;
|
|
276
|
+
const list = document.createElement('ul');
|
|
277
|
+
list.className = 'ov-rows';
|
|
278
|
+
for (const box of mine) list.appendChild(renderRow(box, costByBuilder));
|
|
279
|
+
sec.appendChild(list);
|
|
280
|
+
dock.appendChild(sec);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The glass strip above the panel: the shell's .stat-row, each tile a figure
|
|
285
|
+
// with the one clause that makes it a fact rather than a number.
|
|
255
286
|
function renderSummary(boxes, totalUsd) {
|
|
256
287
|
const afloat = boxes.filter((b) => CLOSEABLE.has(b.state)).length;
|
|
257
288
|
const active = boxes.filter((b) => b.state === 'active').length;
|
|
289
|
+
const live = boxes.filter((b) => b.state === 'active' && b.claude_active).length;
|
|
258
290
|
const blocked = boxes.filter((b) => b.blocked && b.blocked.is).length;
|
|
259
|
-
const
|
|
260
|
-
['Boxes live', fmtInt(afloat)],
|
|
261
|
-
['Active now', fmtInt(active)],
|
|
262
|
-
['Blocked builders', fmtInt(blocked)],
|
|
263
|
-
['Total cost', fmtUsd(totalUsd)],
|
|
291
|
+
const tiles = [
|
|
292
|
+
['Boxes live', fmtInt(afloat), `of ${fmtInt(boxes.length)} builder${boxes.length === 1 ? '' : 's'}`],
|
|
293
|
+
['Active now', fmtInt(active), `${fmtInt(live)} with claude live`],
|
|
294
|
+
['Blocked builders', fmtInt(blocked), blocked ? 'barred from opening a box' : 'nobody barred'],
|
|
295
|
+
['Total cost', fmtUsd(totalUsd), 'across every box'],
|
|
264
296
|
];
|
|
265
|
-
document.getElementById('harbor-summary').innerHTML =
|
|
266
|
-
`<div class="
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Sort: live boxes first, then parked, then everything else;
|
|
270
|
-
// within a group, most-recently-touched first (the API already orders by
|
|
271
|
-
// updated_at desc, so a stable sort by group preserves that).
|
|
272
|
-
function sortBoxes(boxes) {
|
|
273
|
-
const rank = (b) => (b.state === 'active' ? 0 : CLOSEABLE.has(b.state) ? 1 : (b.blocked && b.blocked.is) ? 2 : 3);
|
|
274
|
-
return boxes.map((b, i) => [b, i]).sort((a, z) => (rank(a[0]) - rank(z[0])) || (a[1] - z[1])).map((p) => p[0]);
|
|
297
|
+
document.getElementById('harbor-summary').innerHTML = tiles.map(([label, num, sub]) =>
|
|
298
|
+
`<div class="stat-tile"><span class="stat-tile__label">${escapeHtml(label)}</span><span class="stat-tile__num">${num}</span><span class="stat-tile__sub">${escapeHtml(sub)}</span></div>`).join('');
|
|
275
299
|
}
|
|
276
300
|
|
|
277
301
|
async function load() {
|
|
@@ -285,7 +309,7 @@
|
|
|
285
309
|
} catch (err) {
|
|
286
310
|
if (err && err.kind === '401') return;
|
|
287
311
|
if (err && err.kind === '403') { showSealed(); return; }
|
|
288
|
-
dock.innerHTML =
|
|
312
|
+
dock.innerHTML = emptyStateHtml('Could not load dev boxes just now.');
|
|
289
313
|
return;
|
|
290
314
|
}
|
|
291
315
|
|
|
@@ -301,21 +325,23 @@
|
|
|
301
325
|
renderSummary(boxes, totalUsd);
|
|
302
326
|
|
|
303
327
|
if (boxes.length === 0) {
|
|
304
|
-
dock.innerHTML =
|
|
328
|
+
dock.innerHTML = emptyStateHtml('No dev boxes yet. No builder has opened one.');
|
|
305
329
|
return;
|
|
306
330
|
}
|
|
307
|
-
|
|
308
|
-
for (const box of sortBoxes(boxes)) dock.appendChild(renderContainer(box, costByBuilder));
|
|
331
|
+
renderDock(boxes, costByBuilder);
|
|
309
332
|
}
|
|
310
333
|
|
|
311
334
|
function showSealed() {
|
|
312
335
|
document.getElementById('harbor-sealed').hidden = false;
|
|
313
336
|
document.getElementById('harbor-body').hidden = true;
|
|
314
|
-
document.getElementById('harbor-sub').textContent = '
|
|
337
|
+
document.getElementById('harbor-sub').textContent = 'Restricted.';
|
|
338
|
+
// The floor in the pack's own words (ADR 0157: metic), never a rank spelled into the source.
|
|
339
|
+
const body = document.getElementById('harbor-sealed-body');
|
|
340
|
+
if (body) body.textContent = `Available from the ${rankLabel('metic')} rank up.`;
|
|
315
341
|
}
|
|
316
342
|
|
|
317
343
|
async function init() {
|
|
318
|
-
// Confirm rank
|
|
344
|
+
// Confirm the viewer's rank for a friendly restricted notice; the server gates
|
|
319
345
|
// the page + every endpoint regardless (this is cosmetic defence-in-depth).
|
|
320
346
|
let me;
|
|
321
347
|
try {
|
|
@@ -45,6 +45,23 @@
|
|
|
45
45
|
},
|
|
46
46
|
"out": {
|
|
47
47
|
"auth": false
|
|
48
|
+
},
|
|
49
|
+
"block": {
|
|
50
|
+
"auth": true,
|
|
51
|
+
"actions": [
|
|
52
|
+
[
|
|
53
|
+
"wait",
|
|
54
|
+
700
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
"clickIfVisible",
|
|
58
|
+
"[data-act=block]"
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
"wait",
|
|
62
|
+
250
|
|
63
|
+
]
|
|
64
|
+
]
|
|
48
65
|
}
|
|
49
66
|
},
|
|
50
67
|
"reducedMotion": {
|
|
@@ -55,5 +72,6 @@
|
|
|
55
72
|
600
|
|
56
73
|
]
|
|
57
74
|
]
|
|
58
|
-
}
|
|
75
|
+
},
|
|
76
|
+
"_states": "task 1003469: `block` opens the confirm modal on the first row that has a Block action. clickIfVisible, because the kit's own stub carries no /boxes fixture and renders the empty dock — the modal is measured against the harness, where the fixture fleet renders."
|
|
59
77
|
}
|