@homeostat/dashboard 0.1.6 → 0.1.7
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/dist/src/automation.d.ts +28 -0
- package/dist/src/automation.js +138 -18
- package/dist/src/charts.d.ts +19 -0
- package/dist/src/charts.js +71 -1
- package/dist/src/server.js +36 -8
- package/dist/src/views.d.ts +30 -3
- package/dist/src/views.js +490 -182
- package/package.json +2 -2
package/dist/src/views.js
CHANGED
|
@@ -1,155 +1,285 @@
|
|
|
1
|
-
import { describeEvent,
|
|
2
|
-
import { columns, dataTable, dial, esc, rows } from "./charts.js";
|
|
1
|
+
import { describeEvent, isRejectedDecision, STATUSES, score, } from "@homeostat/core";
|
|
2
|
+
import { areaChart, columns, dataTable, dial, esc, info, rings, rows, } from "./charts.js";
|
|
3
3
|
import { currentValue, SECTIONS } from "./settings.js";
|
|
4
4
|
const CSS = `
|
|
5
5
|
:root {
|
|
6
|
-
color-scheme: light;
|
|
7
|
-
--paper: #f6f5f1;
|
|
8
|
-
--surface: #fcfcfb;
|
|
9
|
-
--ink: #0b0b0b;
|
|
10
|
-
--ink-2: #52514e;
|
|
11
|
-
--ink-3: #85847e;
|
|
12
|
-
--rule: #e2e0d9;
|
|
13
|
-
--grid: rgba(11, 11, 11, 0.035);
|
|
14
|
-
--series: #2a78d6;
|
|
15
|
-
--series-soft: #cde2fb;
|
|
16
|
-
--good: #0ca30c;
|
|
17
|
-
--warning: #b37a00;
|
|
18
|
-
--critical: #d03b3b;
|
|
19
|
-
--focus: #2a78d6;
|
|
20
|
-
}
|
|
21
|
-
@media (prefers-color-scheme: dark) {
|
|
22
|
-
:root:not([data-theme="light"]) {
|
|
23
|
-
color-scheme: dark;
|
|
24
|
-
--paper: #121211;
|
|
25
|
-
--surface: #1a1a19;
|
|
26
|
-
--ink: #ffffff;
|
|
27
|
-
--ink-2: #c3c2b7;
|
|
28
|
-
--ink-3: #8f8e86;
|
|
29
|
-
--rule: #2e2e2b;
|
|
30
|
-
--grid: rgba(255, 255, 255, 0.03);
|
|
31
|
-
--series: #3987e5;
|
|
32
|
-
--series-soft: #184f95;
|
|
33
|
-
--good: #0ca30c;
|
|
34
|
-
--warning: #fab219;
|
|
35
|
-
--critical: #e66767;
|
|
36
|
-
--focus: #3987e5;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
:root[data-theme="dark"] {
|
|
40
6
|
color-scheme: dark;
|
|
41
|
-
--paper: #
|
|
42
|
-
--
|
|
43
|
-
--
|
|
44
|
-
--
|
|
45
|
-
--ink
|
|
46
|
-
--
|
|
47
|
-
--
|
|
48
|
-
--
|
|
49
|
-
--
|
|
50
|
-
--
|
|
51
|
-
--
|
|
52
|
-
--
|
|
7
|
+
--paper: #121316;
|
|
8
|
+
--side: #17181c;
|
|
9
|
+
--surface: #1e1f24;
|
|
10
|
+
--raised: #26272d;
|
|
11
|
+
--ink: #f4f4f6;
|
|
12
|
+
--ink-2: #b4b5bd;
|
|
13
|
+
--ink-3: #8a8b94;
|
|
14
|
+
--rule: #2c2d34;
|
|
15
|
+
--accent: #8b5cf6;
|
|
16
|
+
--accent-2: #3b82f6;
|
|
17
|
+
--series: #8b5cf6;
|
|
18
|
+
--series-soft: #3b2a63;
|
|
19
|
+
--good: #22c55e;
|
|
20
|
+
--warning: #f59e0b;
|
|
21
|
+
--critical: #ef4444;
|
|
22
|
+
--pink: #ec4899;
|
|
23
|
+
--focus: #a78bfa;
|
|
24
|
+
--sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI Variable Display", "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
25
|
+
--code: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
|
26
|
+
--radius: 20px;
|
|
27
|
+
}
|
|
28
|
+
:root[data-theme="light"] {
|
|
29
|
+
color-scheme: light;
|
|
30
|
+
--paper: #f3f4f7;
|
|
31
|
+
--side: #ffffff;
|
|
32
|
+
--surface: #ffffff;
|
|
33
|
+
--raised: #f1f2f6;
|
|
34
|
+
--ink: #16171b;
|
|
35
|
+
--ink-2: #4b4d57;
|
|
36
|
+
--ink-3: #6b6d78;
|
|
37
|
+
--rule: #e4e5ea;
|
|
38
|
+
--series-soft: #e9e1fd;
|
|
39
|
+
--good: #16a34a;
|
|
40
|
+
--warning: #d97706;
|
|
41
|
+
--critical: #dc2626;
|
|
42
|
+
--focus: #7c3aed;
|
|
53
43
|
}
|
|
54
44
|
* { box-sizing: border-box; }
|
|
55
|
-
body {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
45
|
+
body { margin: 0; background: var(--paper); color: var(--ink); font: 15px/1.5 var(--sans); -webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums; }
|
|
46
|
+
a { color: var(--accent-2); text-decoration: none; }
|
|
47
|
+
a:hover { text-decoration: underline; }
|
|
48
|
+
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 6px; }
|
|
49
|
+
a, button, summary, select, input[type=checkbox] { cursor: pointer; }
|
|
50
|
+
code, .mono { font-family: var(--code); font-size: 0.88em; }
|
|
51
|
+
h1, h2, h3 { margin: 0; font-weight: 650; letter-spacing: -0.01em; line-height: 1.2; }
|
|
52
|
+
h2 { font-size: 18px; display: flex; align-items: center; gap: 6px; }
|
|
53
|
+
h2 a { color: inherit; }
|
|
54
|
+
h3 { font-size: 15px; }
|
|
55
|
+
.num { text-align: right; }
|
|
56
|
+
.muted { color: var(--ink-2); }
|
|
57
|
+
.empty { color: var(--ink-3); }
|
|
58
|
+
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
59
|
+
|
|
60
|
+
/* App frame: sidebar + main. */
|
|
61
|
+
.app { display: grid; grid-template-columns: 236px minmax(0, 1fr); min-height: 100vh; }
|
|
62
|
+
.side { background: var(--side); border-right: 1px solid var(--rule); padding: 22px 14px; display: flex; flex-direction: column; gap: 22px; position: sticky; top: 0; height: 100vh; }
|
|
63
|
+
.logo { display: flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 700; font-size: 18px; padding: 0 8px; }
|
|
64
|
+
.logo:hover { text-decoration: none; }
|
|
65
|
+
.logo svg { flex: none; }
|
|
66
|
+
.menu { display: grid; gap: 4px; }
|
|
67
|
+
.menu a { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 12px; color: var(--ink-2); font-weight: 550; }
|
|
68
|
+
.menu a:hover { background: var(--raised); color: var(--ink); text-decoration: none; }
|
|
69
|
+
.menu a[aria-current="page"] { background: var(--raised); color: var(--ink); }
|
|
70
|
+
.menu svg { flex: none; }
|
|
71
|
+
.side-foot { margin-top: auto; display: grid; gap: 12px; padding: 0 8px; }
|
|
72
|
+
.kbname { color: var(--ink-3); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
73
|
+
.switch { display: flex; align-items: center; justify-content: space-between; color: var(--ink); font-weight: 550; }
|
|
74
|
+
.switch:hover { text-decoration: none; }
|
|
75
|
+
.switch .track { width: 38px; height: 22px; border-radius: 999px; background: var(--raised); border: 1px solid var(--rule); position: relative; transition: background .2s; }
|
|
76
|
+
.switch .knob { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--ink-3); transition: transform .2s, background .2s; }
|
|
77
|
+
.switch.on .track { background: var(--accent); border-color: var(--accent); }
|
|
78
|
+
.switch.on .knob { transform: translateX(16px); background: #fff; }
|
|
79
|
+
.main { min-width: 0; padding: 22px 32px 48px; }
|
|
80
|
+
.topbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 26px; }
|
|
81
|
+
.search { flex: 1 1 260px; max-width: 360px; position: relative; }
|
|
82
|
+
.search input { width: 100%; background: var(--surface); border: 1px solid var(--rule); color: var(--ink); border-radius: 12px; padding: 11px 14px 11px 40px; font: 15px var(--sans); }
|
|
83
|
+
.search svg { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--ink-3); }
|
|
84
|
+
.status { display: flex; gap: 8px; flex-wrap: wrap; margin-left: auto; }
|
|
85
|
+
.pill { display: inline-flex; align-items: center; gap: 7px; padding: 7px 12px; border-radius: 999px; background: var(--surface); border: 1px solid var(--rule); color: var(--ink-2); font-size: 13px; font-weight: 550; white-space: nowrap; }
|
|
86
|
+
.pill:hover { color: var(--ink); text-decoration: none; border-color: var(--ink-3); }
|
|
87
|
+
.pill b { color: var(--ink); }
|
|
88
|
+
.btn { display: inline-flex; align-items: center; gap: 8px; border-radius: 999px; padding: 10px 18px; font-weight: 650; font-size: 14px; }
|
|
89
|
+
.btn.primary { background: var(--accent-2); color: #fff; }
|
|
90
|
+
.btn.primary:hover { filter: brightness(1.1); text-decoration: none; }
|
|
91
|
+
@media (max-width: 900px) {
|
|
92
|
+
.app { grid-template-columns: 1fr; }
|
|
93
|
+
.side { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; padding: 12px 16px; gap: 10px; }
|
|
94
|
+
.menu { display: flex; flex-wrap: wrap; gap: 2px; }
|
|
95
|
+
.menu a { padding: 7px 10px; }
|
|
96
|
+
.menu a span { display: none; }
|
|
97
|
+
.side-foot { margin: 0 0 0 auto; padding: 0; }
|
|
98
|
+
.kbname { display: none; }
|
|
99
|
+
.main { padding: 18px 16px 40px; }
|
|
100
|
+
.status { margin-left: 0; }
|
|
64
101
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
.
|
|
74
|
-
.
|
|
75
|
-
nav { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
|
76
|
-
nav a { color: var(--ink-2); text-decoration: none; font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 0; border-bottom: 1px solid transparent; }
|
|
77
|
-
nav a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--ink); }
|
|
78
|
-
.grid { display: grid; gap: 16px; grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
|
79
|
-
.panel { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; padding: 16px 18px; grid-column: span 12; min-width: 0; }
|
|
80
|
-
.span-4 { grid-column: span 4; } .span-6 { grid-column: span 6; } .span-8 { grid-column: span 8; } .span-12 { grid-column: span 12; }
|
|
81
|
-
@media (max-width: 860px) { .span-4, .span-6, .span-8 { grid-column: span 12; } }
|
|
82
|
-
.label { color: var(--ink-3); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
102
|
+
|
|
103
|
+
/* Greeting and cards. */
|
|
104
|
+
.hello { color: var(--ink-2); margin: 0 0 2px; font-size: 15px; }
|
|
105
|
+
.welcome { font-size: 30px; font-weight: 700; margin: 0 0 22px; }
|
|
106
|
+
.grid { display: grid; gap: 20px; grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
|
107
|
+
.panel, .card { background: var(--surface); border: 1px solid var(--rule); border-radius: var(--radius); padding: 22px 24px; grid-column: span 12; min-width: 0; }
|
|
108
|
+
.span-4 { grid-column: span 4; } .span-5 { grid-column: span 5; } .span-6 { grid-column: span 6; } .span-7 { grid-column: span 7; } .span-8 { grid-column: span 8; } .span-12 { grid-column: span 12; }
|
|
109
|
+
@media (max-width: 1000px) { .span-4, .span-5, .span-6, .span-7, .span-8 { grid-column: span 12; } .grid { gap: 16px; } }
|
|
110
|
+
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
|
111
|
+
.detail-link { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 4px; }
|
|
83
112
|
.lede { color: var(--ink-2); margin: 4px 0 14px; max-width: 70ch; }
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
.
|
|
90
|
-
.
|
|
91
|
-
.
|
|
92
|
-
.
|
|
93
|
-
.
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
.
|
|
97
|
-
.
|
|
98
|
-
.
|
|
99
|
-
.
|
|
100
|
-
.
|
|
101
|
-
.
|
|
102
|
-
.
|
|
103
|
-
.
|
|
104
|
-
.
|
|
105
|
-
.
|
|
106
|
-
.
|
|
107
|
-
.
|
|
108
|
-
.
|
|
109
|
-
.
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
.label { display: block; color: var(--ink-3); font-size: 13px; margin: 10px 0 4px; }
|
|
114
|
+
|
|
115
|
+
/* Hero card. */
|
|
116
|
+
.hero-body { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.4fr); gap: 24px; align-items: end; }
|
|
117
|
+
@media (max-width: 1000px) { .hero-body { grid-template-columns: 1fr; } }
|
|
118
|
+
.tabs { display: inline-flex; gap: 4px; padding: 4px; border-radius: 999px; background: var(--raised); margin-bottom: 26px; }
|
|
119
|
+
.tabs a, .tabs span { padding: 7px 14px; border-radius: 999px; color: var(--ink-3); font-size: 14px; font-weight: 550; }
|
|
120
|
+
.tabs a:hover { color: var(--ink); text-decoration: none; }
|
|
121
|
+
.tabs .on { background: var(--surface); color: var(--ink); box-shadow: 0 1px 2px rgb(0 0 0 / .25); }
|
|
122
|
+
.big { font-size: 56px; font-weight: 750; letter-spacing: -0.03em; line-height: 1; display: flex; align-items: center; gap: 14px; }
|
|
123
|
+
.trend { display: inline-flex; align-items: center; gap: 5px; font-size: 14px; font-weight: 650; letter-spacing: 0; padding: 5px 10px; border-radius: 999px; border: 1px solid var(--rule); }
|
|
124
|
+
.trend.up { color: var(--good); } .trend.down { color: var(--critical); } .trend.flat { color: var(--ink-3); }
|
|
125
|
+
.big-sub { color: var(--ink-3); margin-top: 10px; font-size: 14px; }
|
|
126
|
+
.kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 22px; border: 1px solid var(--rule); border-radius: 16px; }
|
|
127
|
+
.kpi { padding: 16px 20px; border-left: 1px solid var(--rule); }
|
|
128
|
+
.kpi:first-child { border-left: 0; }
|
|
129
|
+
.kpi .k { color: var(--ink-2); font-size: 14px; display: block; margin-bottom: 6px; }
|
|
130
|
+
.kpi .v { font-size: 24px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
|
131
|
+
@media (max-width: 700px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } .kpi:nth-child(3) { border-left: 0; } .kpi:nth-child(n+3) { border-top: 1px solid var(--rule); } }
|
|
132
|
+
.area { width: 100%; height: auto; display: block; overflow: visible; }
|
|
133
|
+
.area .curve { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; }
|
|
134
|
+
.area .gridline { stroke: var(--rule); stroke-dasharray: 3 5; }
|
|
135
|
+
.area .xl { fill: var(--ink-3); font: 12px var(--sans); }
|
|
136
|
+
.area .pt { fill: transparent; }
|
|
137
|
+
.area .pt:hover { fill: color-mix(in srgb, var(--accent) 30%, transparent); }
|
|
138
|
+
.area .last { fill: var(--accent); stroke: #fff; stroke-width: 2.5; }
|
|
139
|
+
.area .callout rect { fill: var(--ink); }
|
|
140
|
+
.area .callout .cl { fill: var(--ink-3); font: 11px var(--sans); }
|
|
141
|
+
.area .callout .cv { fill: var(--paper); font: 700 14px var(--sans); }
|
|
142
|
+
|
|
143
|
+
/* Decisions card: total, legend, rings. */
|
|
144
|
+
.split { display: grid; grid-template-columns: minmax(0, 1fr) 200px; gap: 16px; align-items: center; }
|
|
145
|
+
@media (max-width: 520px) { .split { grid-template-columns: 1fr; } }
|
|
146
|
+
.total .k { color: var(--ink-2); font-size: 14px; }
|
|
147
|
+
.total .v { font-size: 34px; font-weight: 750; display: block; margin: 2px 0 14px; }
|
|
148
|
+
.legend { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
|
149
|
+
.legend li { display: flex; align-items: center; gap: 10px; font-weight: 550; }
|
|
150
|
+
.legend .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
|
151
|
+
.legend b { margin-left: auto; padding-right: 8px; }
|
|
152
|
+
.rings { width: 100%; max-width: 200px; display: block; }
|
|
153
|
+
.rings .ring-track { fill: none; stroke: var(--raised); stroke-width: 11; }
|
|
154
|
+
.rings .ring { fill: none; stroke-width: 11; stroke-linecap: round; }
|
|
155
|
+
|
|
156
|
+
/* Pills for status in tables. */
|
|
157
|
+
.chip, .status-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12.5px; font-weight: 650; white-space: nowrap; background: var(--raised); color: var(--ink-2); }
|
|
158
|
+
.chip::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
|
159
|
+
.chip.accepted, .status-pill.merged { background: color-mix(in srgb, var(--good) 18%, transparent); color: var(--good); }
|
|
160
|
+
.chip.candidate, .status-pill.review { background: color-mix(in srgb, var(--warning) 18%, transparent); color: var(--warning); }
|
|
161
|
+
.chip.disputed, .status-pill.declined { background: color-mix(in srgb, var(--critical) 18%, transparent); color: var(--critical); }
|
|
162
|
+
.chip.retired { color: var(--ink-3); }
|
|
163
|
+
|
|
164
|
+
/* Tables. */
|
|
115
165
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
116
|
-
th { text-align: left;
|
|
117
|
-
td { border-bottom: 1px solid var(--rule);
|
|
166
|
+
th { text-align: left; color: var(--ink-3); font-weight: 550; font-size: 13px; padding: 8px 10px; border-bottom: 1px solid var(--rule); }
|
|
167
|
+
td { padding: 11px 10px; border-bottom: 1px solid var(--rule); vertical-align: middle; }
|
|
118
168
|
tr:last-child td { border-bottom: 0; }
|
|
119
|
-
.num { text-align: right; }
|
|
120
169
|
.table-wrap { overflow-x: auto; }
|
|
121
|
-
.
|
|
122
|
-
.
|
|
123
|
-
.
|
|
124
|
-
.
|
|
125
|
-
.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
.
|
|
170
|
+
.table-wrap > table { min-width: 620px; }
|
|
171
|
+
.table-wrap.fit > table { min-width: 0; table-layout: fixed; }
|
|
172
|
+
.decided td:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
173
|
+
.decided th:nth-child(2), .decided td:nth-child(2) { width: 24%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
174
|
+
.decided th:nth-child(3) { width: 112px; } .decided th:nth-child(4) { width: 64px; }
|
|
175
|
+
.recent { table-layout: fixed; }
|
|
176
|
+
.recent td { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
177
|
+
.recent .when { width: 104px; color: var(--ink-3); }
|
|
178
|
+
.recent .what { width: 160px; }
|
|
179
|
+
.log:not(.recent) .when { white-space: nowrap; color: var(--ink-3); width: 1%; }
|
|
180
|
+
.log:not(.recent) .what { white-space: nowrap; width: 1%; }
|
|
181
|
+
.log .detail { overflow-wrap: anywhere; color: var(--ink-2); }
|
|
182
|
+
.kind { font-size: 13px; color: var(--ink-2); }
|
|
183
|
+
.sym { display: inline-block; width: 1.3em; color: var(--ink-3); }
|
|
184
|
+
.sym.learn { color: var(--good); } .sym.alert { color: var(--critical); } .sym.run { color: var(--accent); }
|
|
185
|
+
.filters { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 16px; }
|
|
186
|
+
.filters a { padding: 6px 12px; border-radius: 999px; background: var(--raised); color: var(--ink-2); font-size: 13px; font-weight: 550; }
|
|
187
|
+
.filters a:hover { color: var(--ink); text-decoration: none; }
|
|
188
|
+
.filters a[aria-current="page"] { background: var(--accent); color: #fff; }
|
|
189
|
+
|
|
190
|
+
/* Small multiples. */
|
|
191
|
+
.stats, .mini { display: grid; grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); gap: 14px; margin: 8px 0 12px; }
|
|
192
|
+
@media (max-width: 600px) { .stats, .mini { --cols: 2 !important; } }
|
|
193
|
+
.stat .n, .mini .n { display: block; font-size: 26px; font-weight: 700; }
|
|
194
|
+
.stat .k, .mini .k { color: var(--ink-2); font-size: 13px; }
|
|
195
|
+
.lamp { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--ink-3); flex: none; }
|
|
196
|
+
.lamp.good { background: var(--good); } .lamp.warning { background: var(--warning); } .lamp.critical { background: var(--critical); }
|
|
197
|
+
.icon { flex: none; } .icon.good { color: var(--good); } .icon.warning { color: var(--warning); } .icon.critical { color: var(--critical); } .icon.off { color: var(--ink-3); }
|
|
198
|
+
.state { font-size: 13px; color: var(--ink-2); }
|
|
199
|
+
.tag { display: inline-block; font-size: 12.5px; font-weight: 600; color: var(--ink-2); padding: 3px 10px; border-radius: 999px; background: var(--raised); }
|
|
200
|
+
.tag.no { color: var(--critical); background: color-mix(in srgb, var(--critical) 16%, transparent); }
|
|
201
|
+
|
|
202
|
+
/* Bars and dial (other pages). */
|
|
203
|
+
.chart { width: 100%; max-width: 680px; height: auto; display: block; overflow: visible; }
|
|
204
|
+
.chart path { fill: var(--accent); }
|
|
205
|
+
.chart .hit { fill: transparent; }
|
|
206
|
+
.chart .m:hover path { fill: var(--accent-2); }
|
|
207
|
+
.chart .base { stroke: var(--rule); }
|
|
208
|
+
.chart text { font: 12px var(--sans); fill: var(--ink-3); }
|
|
209
|
+
.chart text.v { fill: var(--ink); }
|
|
210
|
+
.dial { width: 100%; max-width: 220px; display: block; margin: 0 auto; }
|
|
211
|
+
.dial .track { fill: none; stroke: var(--raised); stroke-width: 10; stroke-linecap: round; }
|
|
212
|
+
.dial .fill { fill: none; stroke: var(--accent); stroke-width: 10; stroke-linecap: round; }
|
|
213
|
+
.dial .tick { stroke: var(--ink-3); } .dial .needle { stroke: var(--ink); stroke-width: 2; } .dial .hub { fill: var(--ink); }
|
|
214
|
+
.dial-value { font: 700 24px var(--sans); fill: var(--ink); } .dial-caption { font: 12px var(--sans); fill: var(--ink-3); }
|
|
215
|
+
details.table-view { margin-top: 8px; } details.table-view summary { color: var(--ink-3); font-size: 13px; }
|
|
216
|
+
|
|
217
|
+
/* (i) tooltips. */
|
|
218
|
+
.info { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 17px; height: 17px; margin-left: 4px; border: 1.3px solid var(--ink-3); border-radius: 50%; color: var(--ink-3); cursor: help; flex: none; }
|
|
219
|
+
.info .i { font: italic 700 11px/1 Georgia, serif; }
|
|
220
|
+
.info:hover, .info:focus-visible { border-color: var(--accent); color: var(--accent); }
|
|
221
|
+
.info .tip { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
222
|
+
.info:hover .tip, .info:focus .tip { clip: auto; width: max-content; max-width: min(320px, 78vw); height: auto; overflow: visible; white-space: normal; left: -10px; top: calc(100% + 10px); z-index: 30; background: var(--raised); color: var(--ink); border: 1px solid var(--rule); font: 400 13.5px/1.5 var(--sans); letter-spacing: 0; text-align: left; padding: 10px 12px; border-radius: 12px; box-shadow: 0 12px 30px rgb(0 0 0 / .35); }
|
|
223
|
+
|
|
224
|
+
/* Entry pages. */
|
|
225
|
+
.entry-head { display: grid; gap: 10px; margin: 6px 0 20px; }
|
|
226
|
+
.entry-head h2 { font-size: 26px; }
|
|
227
|
+
.entry-head .tags { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
228
|
+
.statement { margin: 0 0 18px; padding: 16px 18px; border-radius: 14px; background: var(--raised); max-width: 72ch; }
|
|
229
|
+
.statement .label { margin: 0 0 4px; }
|
|
230
|
+
.statement p { margin: 0; }
|
|
231
|
+
.statement.rule p { font-size: 18px; font-weight: 550; }
|
|
232
|
+
.statement.rejected p { font-size: 18px; text-decoration: line-through; text-decoration-color: var(--critical); }
|
|
233
|
+
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 8px 24px; margin: 20px 0 0; }
|
|
234
|
+
.facts dt { color: var(--ink-3); font-size: 13px; padding-top: 2px; }
|
|
235
|
+
.facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
|
236
|
+
.reasons { list-style: none; padding: 0; margin: 4px 0 0; }
|
|
237
|
+
.reasons .d { display: inline-block; width: 3ch; text-align: right; margin-right: 8px; font-weight: 700; }
|
|
238
|
+
.reasons .d.pos { color: var(--good); } .reasons .d.neg { color: var(--critical); }
|
|
239
|
+
.flag { border-left: 3px solid var(--warning); padding-left: 12px; margin: 6px 0; }
|
|
240
|
+
|
|
241
|
+
/* Settings. */
|
|
242
|
+
.sections { list-style: none; padding: 0; margin: 10px 0 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
|
|
243
|
+
.sections li { display: flex; align-items: center; gap: 4px; padding: 14px 16px; border-radius: 14px; background: var(--raised); font-weight: 600; }
|
|
244
|
+
form.settings { display: grid; gap: 16px; max-width: 760px; }
|
|
245
|
+
.field { display: grid; grid-template-columns: 240px 1fr; gap: 6px 18px; align-items: start; }
|
|
131
246
|
@media (max-width: 700px) { .field { grid-template-columns: 1fr; } }
|
|
132
|
-
.field label { padding-top:
|
|
133
|
-
|
|
134
|
-
@media (max-width: 700px) { .field .help { grid-column: 1; } }
|
|
135
|
-
input[type=text], input[type=number], select, textarea { font: 13.5px ui-monospace, Menlo, monospace; color: var(--ink); background: var(--surface); border: 1px solid var(--rule); border-radius: 5px; padding: 6px 8px; width: 100%; }
|
|
247
|
+
.field label { padding-top: 8px; color: var(--ink); font-weight: 550; display: flex; align-items: center; }
|
|
248
|
+
input[type=text], input[type=number], select, textarea { font: 14px var(--code); color: var(--ink); background: var(--raised); border: 1px solid var(--rule); border-radius: 10px; padding: 8px 10px; width: 100%; }
|
|
136
249
|
textarea { min-height: 84px; resize: vertical; }
|
|
137
|
-
.checks { display: flex; flex-wrap: wrap; gap: 6px
|
|
138
|
-
.rows td { padding:
|
|
250
|
+
.checks { display: flex; flex-wrap: wrap; gap: 6px 18px; padding-top: 6px; }
|
|
251
|
+
.rows td { padding: 5px 8px 5px 0; }
|
|
139
252
|
.rows input[type=text] { min-width: 90px; }
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
253
|
+
.rows select { min-width: 104px; }
|
|
254
|
+
button { font: 650 14px var(--sans); color: #fff; background: var(--accent-2); border: 0; border-radius: 999px; padding: 10px 20px; justify-self: start; }
|
|
255
|
+
button:hover { filter: brightness(1.1); }
|
|
256
|
+
.notice { border-radius: 12px; background: color-mix(in srgb, var(--good) 14%, transparent); padding: 12px 16px; margin-bottom: 16px; }
|
|
257
|
+
.notice.error { background: color-mix(in srgb, var(--critical) 14%, transparent); }
|
|
143
258
|
.notice ul { margin: 6px 0 0; padding-left: 18px; }
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
259
|
+
.units { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
|
260
|
+
.unit { display: grid; gap: 4px; }
|
|
261
|
+
.gauge { display: inline-block; vertical-align: middle; }
|
|
262
|
+
.gauge .trough { fill: none; stroke: var(--raised); stroke-linecap: round; }
|
|
263
|
+
.gauge .rim { display: none; } .gauge .zero { stroke: var(--ink-3); }
|
|
264
|
+
.gauge .needle { stroke: var(--accent); stroke-linecap: round; } .gauge .pivot { fill: var(--ink); }
|
|
265
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
|
152
266
|
`;
|
|
267
|
+
/** Stroke icons, drawn here (no icon font, nothing fetched). 20px, currentColor. */
|
|
268
|
+
const ICON = {
|
|
269
|
+
"/": '<rect x="4" y="4" width="6.5" height="6.5" rx="2"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="2"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="2"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="2"/>',
|
|
270
|
+
"/automation": '<path d="M20 12a8 8 0 0 1-14 5.3"/><path d="M4 12a8 8 0 0 1 14-5.3"/><path d="M18 3v4h-4"/><path d="M6 21v-4h4"/>',
|
|
271
|
+
"/entries": '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="5" cy="6" r="1"/><circle cx="5" cy="12" r="1"/><circle cx="5" cy="18" r="1"/>',
|
|
272
|
+
"/members": '<circle cx="9" cy="8" r="3.5"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><path d="M16 4.5a3.5 3.5 0 0 1 0 7M18 14c2 .8 3 2.9 3 6"/>',
|
|
273
|
+
"/coverage": '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r=".8"/>',
|
|
274
|
+
"/impact": '<path d="M13 3L5 13.5h6L10 21l8-10.5h-6z"/>',
|
|
275
|
+
"/documents": '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5M10 13h6M10 17h6"/>',
|
|
276
|
+
"/desire-paths": '<path d="M5 19c3-1 3-5 7-6s4-5 7-6"/><circle cx="5" cy="19" r="1.6"/><circle cx="19" cy="7" r="1.6"/>',
|
|
277
|
+
"/events": '<path d="M3 12h4l2-6 4 12 2-6h6"/>',
|
|
278
|
+
"/settings": '<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>',
|
|
279
|
+
};
|
|
280
|
+
const icon = (path, size = 20) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${path}</svg>`;
|
|
281
|
+
/** Pill labels in the top bar; the full name and reading are in the pill's title. */
|
|
282
|
+
const SHORT = { daily: "Run", queue: "Queue", sync: "Sync", kb: "KB" };
|
|
153
283
|
const NAV = [
|
|
154
284
|
["/", "Overview"],
|
|
155
285
|
["/automation", "Automation"],
|
|
@@ -173,13 +303,23 @@ export function layout(opts) {
|
|
|
173
303
|
<style>${CSS}</style>
|
|
174
304
|
</head>
|
|
175
305
|
<body>
|
|
176
|
-
<div class="
|
|
177
|
-
<
|
|
178
|
-
<
|
|
179
|
-
<nav aria-label="Sections">${NAV.map(([href, label]) => `<a href="${href}"${current(href) ? ' aria-current="page"' : ""}>${label}</a>`).join("")}</nav>
|
|
180
|
-
|
|
181
|
-
<
|
|
182
|
-
<
|
|
306
|
+
<div class="app">
|
|
307
|
+
<aside class="side">
|
|
308
|
+
<a class="logo" href="/" aria-label="Homeostat overview"><svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true"><defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#8b5cf6"/><stop offset="1" stop-color="#3b82f6"/></linearGradient></defs><rect width="30" height="30" rx="9" fill="url(#lg)"/><path d="M7 19a8 8 0 0 1 16 0" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/><path d="M15 19l4-6" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/></svg><span>Homeostat</span></a>
|
|
309
|
+
<nav class="menu" aria-label="Sections">${NAV.map(([href, label]) => `<a href="${href}"${current(href) ? ' aria-current="page"' : ""} title="${label}">${icon(ICON[href] ?? "")}<span>${label}</span></a>`).join("")}</nav>
|
|
310
|
+
<div class="side-foot">
|
|
311
|
+
<span class="kbname" title="${esc(opts.kbRoot)}">${esc(opts.kbRoot.split("/").filter(Boolean).at(-1) ?? opts.kbRoot)}${info("A local view of this KB checkout and its git-ignored event log. It sends nothing anywhere. Settings changes are written to homeostat.yaml here: commit them on a branch and open a PR.")}</span>
|
|
312
|
+
<a class="switch${opts.theme === "light" ? "" : " on"}" href="?theme=${opts.theme === "light" ? "dark" : "light"}" role="switch" aria-checked="${opts.theme === "light" ? "false" : "true"}">Dark mode<span class="track"><span class="knob"></span></span></a>
|
|
313
|
+
</div>
|
|
314
|
+
</aside>
|
|
315
|
+
<div class="main">
|
|
316
|
+
<header class="topbar">
|
|
317
|
+
<form class="search" action="/entries" role="search">${icon('<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.2-4.2"/>', 18)}<input type="search" name="q" placeholder="Search rules" aria-label="Search rules"></form>
|
|
318
|
+
${opts.status?.length ? `<div class="status" aria-label="Status">${opts.status.map((l) => `<a class="pill" href="${l.href}" title="${esc(`${l.label}: ${l.value}${l.detail ? `, ${l.detail}` : ""}`)}">${stateIcon(l.state, 15)}${esc(SHORT[l.key])} <b>${esc(l.value)}</b></a>`).join("")}</div>` : ""}
|
|
319
|
+
<a class="btn primary" href="/automation#queue">${icon('<path d="M4 12l5 5L20 6"/>', 18)}Review queue</a>
|
|
320
|
+
</header>
|
|
321
|
+
<main>${opts.body}</main>
|
|
322
|
+
</div>
|
|
183
323
|
</div>
|
|
184
324
|
</body>
|
|
185
325
|
</html>`;
|
|
@@ -195,12 +335,37 @@ function lampFor(a) {
|
|
|
195
335
|
? { cls: "good", text: "Last run succeeded" }
|
|
196
336
|
: { cls: "critical", text: "Last run failed" };
|
|
197
337
|
}
|
|
338
|
+
/** Cron as words for the common shapes (`0 6 * * *` → daily at 06:00 UTC); anything else as is. */
|
|
339
|
+
export function schedule(expr) {
|
|
340
|
+
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
341
|
+
const m = /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(expr.trim());
|
|
342
|
+
if (!m)
|
|
343
|
+
return `${expr} UTC`;
|
|
344
|
+
const at = `${(m[2] ?? "").padStart(2, "0")}:${(m[1] ?? "").padStart(2, "0")} UTC`;
|
|
345
|
+
return m[3] === "*" ? `daily at ${at}` : `${days[Number(m[3])]}s at ${at}`;
|
|
346
|
+
}
|
|
347
|
+
/** Event families, for colour: what was learned, what went wrong, what ran. Rest is plain. */
|
|
348
|
+
const KIND = {
|
|
349
|
+
"entry-created": "learn",
|
|
350
|
+
"entry-flagged": "alert",
|
|
351
|
+
"handoff-requested": "learn",
|
|
352
|
+
"mcp-unknown-tool": "alert",
|
|
353
|
+
"agent-run": "run",
|
|
354
|
+
"seed-run": "run",
|
|
355
|
+
"auto-merged": "learn",
|
|
356
|
+
};
|
|
357
|
+
/** Routine plumbing: kept in the log, left out of the overview's short list. */
|
|
358
|
+
const ROUTINE = new Set(["model-call", "mcp-call", "doc-injection"]);
|
|
359
|
+
const eventRow = (e, when) => {
|
|
360
|
+
const detail = describeEvent(e);
|
|
361
|
+
return `<tr><td class="when">${esc(when)}</td><td class="what"><span class="sym ${KIND[e.type] ?? ""}" aria-hidden="true">${SYMBOL[KIND[e.type] ?? ""] ?? "·"}</span><span class="kind ${KIND[e.type] ?? ""}">${esc(e.type)}</span></td><td class="detail" title="${esc(detail)}">${esc(detail)}</td></tr>`;
|
|
362
|
+
};
|
|
198
363
|
function unit(a) {
|
|
199
364
|
const lamp = lampFor(a);
|
|
200
365
|
const name = a.agent === "curator" ? "Daily run" : "Deep dive";
|
|
201
366
|
return `<div class="unit">
|
|
202
367
|
<div class="head"><span class="lamp ${lamp.cls}" aria-hidden="true"></span><h3>${name}</h3><span class="state">${lamp.text}</span></div>
|
|
203
|
-
<div class="mono muted">${esc(a.schedule)}
|
|
368
|
+
<div class="mono muted" title="${esc(a.schedule)}">${esc(schedule(a.schedule))}</div>
|
|
204
369
|
<div>Last run <span class="mono">${fmtTime(a.lastRun?.ts)}</span>${a.lastRun?.summary ? ` · ${esc(a.lastRun.summary)}` : ""}</div>
|
|
205
370
|
<div>Next run <span class="mono">${fmtTime(a.nextRun)}</span></div>
|
|
206
371
|
<div>Failures this week <span class="mono">${a.failures7d}</span></div>
|
|
@@ -208,34 +373,141 @@ function unit(a) {
|
|
|
208
373
|
}
|
|
209
374
|
/** The latest events as a compact table: when, what, and the detail cut to one line. */
|
|
210
375
|
function recentTable(events) {
|
|
211
|
-
return `<table class="recent"><tbody>${events
|
|
212
|
-
.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
})
|
|
376
|
+
return `<table class="recent log"><tbody>${events
|
|
377
|
+
.filter((e) => !ROUTINE.has(e.type))
|
|
378
|
+
.slice(0, 10)
|
|
379
|
+
.map((e) => eventRow(e, e.ts.slice(5, 16).replace("T", " ")))
|
|
216
380
|
.join("")}</tbody></table>`;
|
|
217
381
|
}
|
|
218
|
-
|
|
219
|
-
|
|
382
|
+
/**
|
|
383
|
+
* A state as a shape as well as a colour (never colour alone): a tick, a warning triangle, a
|
|
384
|
+
* cross, a dash. Plain strokes in currentColor, so they follow the theme.
|
|
385
|
+
*/
|
|
386
|
+
export function stateIcon(state, size = 16) {
|
|
387
|
+
const shape = {
|
|
388
|
+
good: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
389
|
+
warning: '<path d="M12 3.5l9.5 16.5h-19z"/><path d="M12 10v4.5M12 17.5v.01"/>',
|
|
390
|
+
critical: '<circle cx="12" cy="12" r="9"/><path d="M9 9l6 6M15 9l-6 6"/>',
|
|
391
|
+
off: '<path d="M6 12h12"/>',
|
|
392
|
+
}[state];
|
|
393
|
+
return `<svg class="icon ${state}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${shape}</svg>`;
|
|
394
|
+
}
|
|
395
|
+
/** How far the needle swings from upright for each state, in degrees. */
|
|
396
|
+
const SWING = { good: 0, warning: 30, critical: 62, off: -66 };
|
|
397
|
+
/**
|
|
398
|
+
* One homeostat unit: Ashby's trough of water with a needle on a pivot. Upright is steady; the
|
|
399
|
+
* further the needle swings, the further the loop has drifted from equilibrium.
|
|
400
|
+
*/
|
|
401
|
+
export function gauge(state, width = 120, title = "") {
|
|
402
|
+
const cx = 60;
|
|
403
|
+
const cy = 66;
|
|
404
|
+
const pt = (deg, r) => {
|
|
405
|
+
const a = (deg * Math.PI) / 180;
|
|
406
|
+
return `${(cx + r * Math.sin(a)).toFixed(1)},${(cy - r * Math.cos(a)).toFixed(1)}`;
|
|
407
|
+
};
|
|
408
|
+
const arc = (r) => `M${pt(-70, r)} A${r},${r} 0 0 1 ${pt(70, r)}`;
|
|
409
|
+
const swing = SWING[state];
|
|
410
|
+
const small = width < 60;
|
|
411
|
+
return `<svg class="gauge ${state}" width="${width}" height="${Math.round((width * 72) / 120)}" viewBox="0 0 120 72" role="img" aria-label="${esc(title || STATE_WORD[state])}">
|
|
412
|
+
<path class="trough" d="${arc(48)}" stroke-width="${small ? 14 : 9}"/>${small ? "" : `<path class="rim" d="${arc(54)}"/>`}
|
|
413
|
+
<line class="zero" x1="${cx}" y1="${cy - 58}" x2="${cx}" y2="${cy - 40}"/>
|
|
414
|
+
<g transform="rotate(${swing} ${cx} ${cy})"><line class="needle" x1="${cx}" y1="${cy}" x2="${cx}" y2="${cy - 52}" style="transform-origin:${cx}px ${cy}px;--from:${swing > 0 ? -40 : 40}deg" stroke-width="${small ? 6 : 2.4}"/></g>
|
|
415
|
+
<circle class="pivot" cx="${cx}" cy="${cy}" r="${small ? 7 : 3.5}"/></svg>`;
|
|
416
|
+
}
|
|
417
|
+
/** The console: four lamps, large, each a link to where its detail lives. */
|
|
418
|
+
function consoleRow(ls) {
|
|
419
|
+
return `<div class="units-row" aria-label="Status">${ls
|
|
420
|
+
.map((l) => `<div class="hunit">${gauge(l.state, 120, `${l.label}: ${STATE_WORD[l.state]}`)}<span class="name"><a href="${l.href}">${esc(l.label)}</a>${info(esc(l.about))}</span><span class="reading ${l.state}">${esc(l.value)}</span>${l.detail ? `<span class="detail">${esc(l.detail)}</span>` : ""}</div>`)
|
|
421
|
+
.join("")}</div>`;
|
|
422
|
+
}
|
|
423
|
+
const SYMBOL = { learn: "◆", alert: "▲", run: "▶" };
|
|
424
|
+
const STATE_WORD = {
|
|
425
|
+
good: "all good",
|
|
426
|
+
warning: "needs attention",
|
|
427
|
+
critical: "act now",
|
|
428
|
+
off: "no data yet",
|
|
429
|
+
};
|
|
430
|
+
/** A small arrow for a change: up, down or level. */
|
|
431
|
+
const arrow = (d) => icon(d > 0
|
|
432
|
+
? '<path d="M4 17l6-6 4 4 6-7"/><path d="M15 8h5v5"/>'
|
|
433
|
+
: d < 0
|
|
434
|
+
? '<path d="M4 7l6 6 4-4 6 7"/><path d="M15 16h5v-5"/>'
|
|
435
|
+
: '<path d="M5 12h14"/>', 16);
|
|
436
|
+
export function overviewPage(h, agents, imp, _recent, v, _ls, x = { newThisWeek: 0, decided: [] }) {
|
|
220
437
|
const daily = imp.injectionsByDay.map((d) => ({
|
|
221
|
-
label: d.day.
|
|
438
|
+
label: new Date(`${d.day}T00:00:00Z`).toLocaleDateString("en-GB", {
|
|
439
|
+
day: "numeric",
|
|
440
|
+
month: "short",
|
|
441
|
+
timeZone: "UTC",
|
|
442
|
+
}),
|
|
222
443
|
value: d.count,
|
|
223
|
-
title: `${d.day}: ${d.count}
|
|
444
|
+
title: `${d.day}: ${d.count} sessions given rules`,
|
|
224
445
|
}));
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
446
|
+
const runs = v.runs;
|
|
447
|
+
const last = runs.at(-1);
|
|
448
|
+
const prev = runs.at(-2);
|
|
449
|
+
const sum = (r, k) => r?.members.reduce((n, m) => n + m[k], 0) ?? 0;
|
|
450
|
+
const kpi = (label, now, before, tip) => `<div class="kpi"><span class="k">${label}${info(tip)}</span><span class="v">${before === undefined ? "" : `<span class="trend ${now > before ? "up" : now < before ? "down" : "flat"}" style="border:0;padding:0">${arrow(now - before)}</span>`}${now}</span></div>`;
|
|
451
|
+
const totals = {
|
|
452
|
+
merged: runs.reduce((n, r) => n + sum(r, "merged"), 0),
|
|
453
|
+
review: runs.reduce((n, r) => n + sum(r, "review"), 0),
|
|
454
|
+
declined: runs.reduce((n, r) => n + sum(r, "declined"), 0),
|
|
455
|
+
settled: runs.reduce((n, r) => n + r.settled, 0),
|
|
456
|
+
};
|
|
457
|
+
const segments = [
|
|
458
|
+
{ label: "Merged", value: totals.merged, color: "var(--good)" },
|
|
459
|
+
{ label: "To review", value: totals.review, color: "var(--warning)" },
|
|
460
|
+
{ label: "Declined", value: totals.declined, color: "var(--critical)" },
|
|
461
|
+
{ label: "Settled", value: totals.settled, color: "var(--accent)" },
|
|
462
|
+
];
|
|
463
|
+
const decidedTotal = totals.merged + totals.review + totals.declined;
|
|
464
|
+
const schedules = agents
|
|
465
|
+
.map((a) => `${a.agent === "curator" ? "Daily run" : "Deep dive"}: ${schedule(a.schedule)}.`)
|
|
466
|
+
.join(" ");
|
|
467
|
+
const rows = x.decided.slice(0, 6);
|
|
468
|
+
return `<p class="hello">Hi ${esc(x.name ?? "there")},</p><h1 class="welcome">Welcome back</h1>
|
|
469
|
+
<div class="grid">
|
|
470
|
+
<section class="card span-12" aria-labelledby="kb">
|
|
471
|
+
<div class="card-head"><h2 id="kb">Knowledge${info("The rules every session gets, and how much they're used. Rules in force are accepted and not disputed.")}</h2></div>
|
|
472
|
+
<div class="hero-body">
|
|
473
|
+
<div>
|
|
474
|
+
<div class="tabs" role="tablist"><span class="on" role="tab" aria-selected="true">Rules in force</span><a role="tab" href="/entries?status=candidate">Awaiting review</a><a role="tab" href="/entries?status=disputed">Disputed</a></div>
|
|
475
|
+
<div class="big">${h.byStatus.accepted}<span class="trend ${x.newThisWeek > 0 ? "up" : "flat"}">${arrow(x.newThisWeek)}${x.newThisWeek > 0 ? `+${x.newThisWeek}` : "0"} this week</span></div>
|
|
476
|
+
<p class="big-sub">${h.flagged.length + h.byStatus.disputed} flagged, ${h.stale.length} stale</p>
|
|
477
|
+
</div>
|
|
478
|
+
<div>${areaChart(daily, { title: "Sessions given team rules, per day", id: "sessions" })}</div>
|
|
479
|
+
</div>
|
|
480
|
+
<div class="kpis">
|
|
481
|
+
${kpi("Merged", sum(last, "merged"), prev ? sum(prev, "merged") : undefined, "Lessons the last daily run merged on its own: confident, safe and not repeats.")}
|
|
482
|
+
${kpi("To review", sum(last, "review"), prev ? sum(prev, "review") : undefined, "Lessons the last daily run sent to a reviewer, in a review PR.")}
|
|
483
|
+
${kpi("Declined", sum(last, "declined"), prev ? sum(prev, "declined") : undefined, "Lessons the last daily run declined: weak, invalid, or a repeat of a rule the team already has.")}
|
|
484
|
+
${kpi("Settled", last?.settled ?? 0, prev?.settled, "Review PRs reviewers merged or closed since the run before.")}
|
|
485
|
+
</div>
|
|
486
|
+
</section>
|
|
487
|
+
<section class="card span-5" aria-labelledby="runs">
|
|
488
|
+
<div class="card-head"><h2 id="runs">Daily run${info(`Every decision recorded on the state branch. ${schedules}`)}</h2><a class="detail-link" href="/automation">Detail ${icon('<path d="M9 6l6 6-6 6"/>', 16)}</a></div>
|
|
489
|
+
<div class="split"><div><div class="total"><span class="k">Lessons decided</span><span class="v">${decidedTotal}</span></div>
|
|
490
|
+
<ul class="legend">${segments.map((g) => `<li><span class="dot" style="background:${g.color}"></span>${g.label}<b>${g.value}</b></li>`).join("")}</ul></div>
|
|
491
|
+
${rings(segments, "Lessons decided by the daily run")}</div>
|
|
492
|
+
</section>
|
|
493
|
+
<section class="card span-7" aria-labelledby="decided">
|
|
494
|
+
<div class="card-head"><h2 id="decided">Recent decisions</h2><a class="detail-link" href="/automation">Detail ${icon('<path d="M9 6l6 6-6 6"/>', 16)}</a></div>
|
|
495
|
+
${rows.length
|
|
496
|
+
? `<div class="table-wrap fit"><table class="decided"><thead><tr><th>Lesson</th><th>Developer</th><th>Decision</th><th class="num">Score</th></tr></thead><tbody>${rows
|
|
497
|
+
.map((d) => `<tr><td>${d.id.startsWith("knowledge/") ? esc(d.id.replace(/^knowledge\//, "")) : `<a href="/entries/${esc(d.id)}">${esc(d.id)}</a>`}</td><td>${esc(d.member)}</td><td><span class="status-pill ${esc(d.decision)}">${d.decision === "review" ? "to review" : esc(d.decision)}</span></td><td class="num">${d.confidence ?? "—"}</td></tr>`)
|
|
498
|
+
.join("")}</tbody></table></div>`
|
|
499
|
+
: '<p class="empty">No decisions yet. The first daily run fills this in.</p>'}
|
|
500
|
+
</section>
|
|
234
501
|
</div>`;
|
|
235
502
|
}
|
|
236
|
-
export function entriesPage(entries, status) {
|
|
503
|
+
export function entriesPage(entries, status, query = null) {
|
|
504
|
+
const words = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
505
|
+
const matches = (e) => {
|
|
506
|
+
const hay = `${e.frontmatter.id} ${e.frontmatter.title} ${e.sections.rule ?? ""}`.toLowerCase();
|
|
507
|
+
return words.every((w) => hay.includes(w));
|
|
508
|
+
};
|
|
237
509
|
const shown = entries
|
|
238
|
-
.filter((e) => !status || e.frontmatter.status === status)
|
|
510
|
+
.filter((e) => (!status || e.frontmatter.status === status) && matches(e))
|
|
239
511
|
.sort((a, b) => a.frontmatter.area.localeCompare(b.frontmatter.area) ||
|
|
240
512
|
b.frontmatter.score - a.frontmatter.score);
|
|
241
513
|
const filters = ["all", ...STATUSES]
|
|
@@ -243,20 +515,42 @@ export function entriesPage(entries, status) {
|
|
|
243
515
|
const active = (s === "all" && !status) || s === status;
|
|
244
516
|
return `<a href="/entries${s === "all" ? "" : `?status=${s}`}"${active ? ' aria-current="page"' : ""}>${s}</a>`;
|
|
245
517
|
})
|
|
246
|
-
.join("
|
|
247
|
-
return `<section class="panel"><h2>Entries</h2><
|
|
518
|
+
.join("");
|
|
519
|
+
return `<section class="panel"><h2>Entries</h2><nav class="filters" aria-label="Status">${filters}</nav>
|
|
248
520
|
<div class="table-wrap"><table><thead><tr><th>Status</th><th class="num">Score</th><th>Entry</th><th>Area</th><th>Owner</th><th>Confirmed</th></tr></thead><tbody>
|
|
249
521
|
${shown
|
|
250
|
-
.map((e) => `<tr><td><span class="chip ${e.frontmatter.status}">${e.frontmatter.status}</span></td><td class="num">${e.frontmatter.score}</td><td><a href="/entries/${esc(e.frontmatter.id)}">${esc(e.frontmatter.title)}</a><div class="mono muted">${esc(e.frontmatter.id)}${e.frontmatter.flags?.length ?
|
|
522
|
+
.map((e) => `<tr><td><span class="chip ${e.frontmatter.status}">${e.frontmatter.status}</span></td><td class="num">${e.frontmatter.score}</td><td><a href="/entries/${esc(e.frontmatter.id)}">${esc(e.frontmatter.title)}</a><div class="mono muted">${esc(e.frontmatter.id)}${e.frontmatter.flags?.length ? `, ${e.frontmatter.flags.length} open ${e.frontmatter.flags.length === 1 ? "flag" : "flags"}` : ""}</div></td><td>${esc(e.frontmatter.area)}</td><td>${esc(e.frontmatter.owner)}</td><td class="mono">${esc(e.frontmatter.lastConfirmed)}</td></tr>`)
|
|
251
523
|
.join("")}
|
|
252
524
|
</tbody></table></div>${shown.length ? "" : '<p class="empty">No entries with this status.</p>'}</section>`;
|
|
253
525
|
}
|
|
254
526
|
export function entryPage(entry, config) {
|
|
255
|
-
|
|
256
|
-
|
|
527
|
+
const fm = entry.frontmatter;
|
|
528
|
+
const rejected = isRejectedDecision(fm);
|
|
529
|
+
const reasons = score(fm, { config }).reasons;
|
|
530
|
+
const link = (url) => url && /^https:\/\//.test(url)
|
|
531
|
+
? `, <a href="${esc(url)}">${esc(url.replace(/^https:\/\/[^/]+\//, ""))}</a>`
|
|
532
|
+
: "";
|
|
533
|
+
const fact = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
|
|
534
|
+
return `<section class="panel"><p><a href="/entries">Back to entries</a></p>
|
|
535
|
+
<header class="entry-head"><h2>${esc(fm.title)}</h2>
|
|
536
|
+
<div class="tags"><span class="chip ${fm.status}">${fm.status}</span>${rejected ? `<span class="tag no">decided against</span>${info("Considered and not adopted. Agents see it as context, never as an instruction, so they stop proposing it unless something below no longer holds.")}` : fm.kind ? `<span class="tag">${esc(fm.kind)}</span>` : ""}<span class="tag">${esc(fm.area)}</span><span class="mono muted">${esc(fm.id)}</span></div></header>
|
|
537
|
+
<div class="statement ${rejected ? "rejected" : "rule"}"><span class="label">${rejected ? "Proposal" : "Rule"}</span><p>${esc(entry.sections.rule ?? "")}</p></div>
|
|
538
|
+
<div class="statement"><span class="label">${rejected ? "Why not" : "Why"}</span><p>${esc(entry.sections.why ?? "")}</p></div>
|
|
539
|
+
${entry.sections.example ? `<div class="statement"><span class="label">Example</span><p class="mono">${esc(entry.sections.example)}</p></div>` : ""}
|
|
540
|
+
${(fm.flags ?? []).map((f) => `<p class="flag">Flagged by <span class="mono">${esc(f.by)}</span> on <span class="mono">${esc(f.date)}</span>: ${esc(f.reason)}</p>`).join("")}
|
|
541
|
+
<dl class="facts">
|
|
542
|
+
${fact("Applies to", fm.scope.map((s) => `<code>${esc(s)}</code>`).join(", "))}
|
|
543
|
+
${fact("Owner", `<span class="mono">${esc(fm.owner)}</span>`)}
|
|
544
|
+
${fact("Source", `${esc(fm.source.type)} by <span class="mono">${esc(fm.source.author)}</span>${fm.source.changedCode ? ", and the code changed after the comment" : ""}${link(fm.source.url)}`)}
|
|
545
|
+
${fact("Score", `<span class="mono">${fm.score}</span>${reasons.length ? `<ul class="reasons">${reasons.map((r) => `<li><span class="d ${r.delta > 0 ? "pos" : "neg"}">${r.delta > 0 ? "+" : ""}${r.delta}</span>${esc(r.note)}</li>`).join("")}</ul>` : ""}`)}
|
|
546
|
+
${fact("Dates", `created ${esc(fm.created)}, last confirmed ${esc(fm.lastConfirmed)}${fm.approvedBy ? `, approved by <span class="mono">${esc(fm.approvedBy)}</span>` : ""}`)}
|
|
547
|
+
${fm.conflictsWith?.length ? fact("Conflicts with", fm.conflictsWith.map((c) => `<a href="/entries/${esc(c)}">${esc(c)}</a>`).join(", ")) : ""}
|
|
548
|
+
${fm.supersedes ? fact("Supersedes", `<a href="/entries/${esc(fm.supersedes)}">${esc(fm.supersedes)}</a>`) : ""}
|
|
549
|
+
</dl>
|
|
550
|
+
<p style="margin-top:18px"><code>homeostat flag ${esc(fm.id)} --reason "…"</code>${info("If this rule is wrong or out of date, flag it: it leaves sessions at once and goes back for review.")}</p></section>`;
|
|
257
551
|
}
|
|
258
552
|
export function membersPage(rowsData) {
|
|
259
|
-
return `<section class="panel"><h2>Members
|
|
553
|
+
return `<section class="panel"><h2>Members${info("What each person's sessions, reviews and captures have fed into the shared knowledge base. Listed alphabetically: this is contribution to a shared product, not a ranking.")}</h2>
|
|
260
554
|
<div class="table-wrap"><table><thead><tr><th>Member</th><th>Role</th><th class="num">Entries authored</th><th class="num">Accepted</th><th class="num">Accepted share</th><th class="num">Captured, 30 days</th><th class="num">Flags raised</th></tr></thead><tbody>
|
|
261
555
|
${rowsData
|
|
262
556
|
.map((c) => `<tr><td class="mono">${esc(c.member)}</td><td>${esc(c.role)}</td><td class="num">${c.authored}</td><td class="num">${c.accepted}</td><td class="num">${c.acceptedRatio === undefined ? "—" : pct(c.acceptedRatio)}</td><td class="num">${c.captured30d}</td><td class="num">${c.flagsRaised}</td></tr>`)
|
|
@@ -293,7 +587,7 @@ export function impactPage(imp) {
|
|
|
293
587
|
return `<div class="grid"><section class="panel"><div class="stats" style="--cols: 4">${stat(imp.sessions, "sessions with rules")}${stat(imp.rulesPerInjection, "rules per session")}${stat(imp.tokensPerInjection, "tokens per session")}${stat(imp.toolUse.reduce((n, t) => n + t.calls, 0), "MCP tool calls")}</div></section>
|
|
294
588
|
<section class="panel span-6"><h2>Injections per day</h2>${columns(inj, { title: "Injections per day, last 14 days", labelled: "ends", tickEvery: 7 })}${dataTable(inj, ["Day", "Injections"])}</section>
|
|
295
589
|
<section class="panel span-6"><h2>MCP calls per day</h2>${columns(mcp, { title: "MCP calls per day, last 14 days", labelled: "ends", tickEvery: 7 })}${dataTable(mcp, ["Day", "Calls"])}</section>
|
|
296
|
-
<section class="panel span-8"><h2>Most-used rules</h2>${top.length ? rows(top, { title: "Times each rule was put into a session" }) + dataTable(top, ["Rule", "Injections"]) : '<p class="empty">No injections recorded yet.</p>'}</section>
|
|
590
|
+
<section class="panel span-8"><h2>Most-used rules</h2>${top.length ? rows(top, { title: "Times each rule was put into a session", labelWidth: 320 }) + dataTable(top, ["Rule", "Injections"]) : '<p class="empty">No injections recorded yet.</p>'}</section>
|
|
297
591
|
<section class="panel span-4"><h2>Tools agents call</h2>${imp.toolUse.length ? `<table><thead><tr><th>Tool</th><th class="num">Calls</th></tr></thead><tbody>${imp.toolUse.map((t) => `<tr><td class="mono">${esc(t.tool)}</td><td class="num">${t.calls}</td></tr>`).join("")}</tbody></table>` : '<p class="empty">No MCP calls recorded yet.</p>'}</section></div>`;
|
|
298
592
|
}
|
|
299
593
|
export function documentsPage(usage) {
|
|
@@ -302,7 +596,7 @@ export function documentsPage(usage) {
|
|
|
302
596
|
const docs = new Set(usage.map((u) => u.doc)).size;
|
|
303
597
|
const used = usage.filter((u) => u.reads + u.injected > 0).length;
|
|
304
598
|
return `<div class="grid"><section class="panel"><div class="stats" style="--cols: 4">${stat(docs, "documents")}${stat(usage.length, "sections")}${stat(used, "used in 90 days")}${stat(decaying.length, "to review")}</div></section>
|
|
305
|
-
<section class="panel"><h2>Section usage
|
|
599
|
+
<section class="panel"><h2>Section usage${info("Reads through MCP and sessions each section was given in full, from this machine's event log over the last 90 days. A section nobody used or confirmed in 90 days is a review candidate: its owner confirms, updates or removes it.")}</h2>
|
|
306
600
|
${usage.length
|
|
307
601
|
? `<div class="table-wrap"><table><thead><tr><th>Section</th><th class="num">Reads</th><th class="num">In sessions</th><th>Last used</th><th>Confirmed</th><th></th></tr></thead><tbody>${usage
|
|
308
602
|
.map((u) => `<tr><td><span class="mono">${esc(u.doc)}#${esc(u.id)}</span><br>${esc(u.heading)}</td><td class="num">${u.reads}</td><td class="num">${u.injected}</td><td class="mono">${u.lastUsed ? fmtTime(u.lastUsed) : "never"}</td><td class="mono">${esc(u.confirmed)}</td><td>${u.decaying ? '<span class="chip disputed">review</span>' : ""}</td></tr>`)
|
|
@@ -310,19 +604,33 @@ ${usage.length
|
|
|
310
604
|
: '<p class="empty">No documents yet. Add them under knowledge/<area>/docs/, or run homeostat docs convert or homeostat seed.</p>'}</section></div>`;
|
|
311
605
|
}
|
|
312
606
|
export function desirePathsPage(paths) {
|
|
313
|
-
return `<section class="panel"><h2>Desire paths
|
|
607
|
+
return `<section class="panel"><h2>Desire paths${info("Tool names agents tried that are not the real ones. Resolved guesses already work as aliases; unresolved ones that keep coming back are worth turning into real tools.")}</h2>
|
|
314
608
|
${paths.length
|
|
315
609
|
? `<div class="table-wrap"><table><thead><tr><th>Name tried</th><th>Resolved to</th><th class="num">Times</th><th>Last</th></tr></thead><tbody>${paths
|
|
316
610
|
.map((p) => `<tr><td class="mono">${esc(p.name)}</td><td>${p.resolved ? `<span class="mono">${esc(p.resolved)}</span>` : '<span class="chip disputed">unknown</span>'}</td><td class="num">${p.count}</td><td class="mono">${fmtTime(p.last)}</td></tr>`)
|
|
317
611
|
.join("")}</tbody></table></div>`
|
|
318
612
|
: '<p class="empty">No guessed tool names yet.</p>'}</section>`;
|
|
319
613
|
}
|
|
320
|
-
export function eventsPage(events) {
|
|
321
|
-
|
|
614
|
+
export function eventsPage(events, type = null) {
|
|
615
|
+
const counts = new Map();
|
|
616
|
+
for (const e of events)
|
|
617
|
+
counts.set(e.type, (counts.get(e.type) ?? 0) + 1);
|
|
618
|
+
const filters = [
|
|
619
|
+
`<a href="/events"${type ? "" : ' aria-current="page"'}>all ${events.length}</a>`,
|
|
620
|
+
...[...counts]
|
|
621
|
+
.sort((a, b) => b[1] - a[1])
|
|
622
|
+
.map(([t, n]) => `<a href="/events?type=${encodeURIComponent(t)}"${t === type ? ' aria-current="page"' : ""}>${esc(t)} ${n}</a>`),
|
|
623
|
+
].join("");
|
|
624
|
+
const shown = type ? events.filter((e) => e.type === type) : events;
|
|
625
|
+
return `<section class="panel"><h2>Events, last 7 days</h2>${events.length
|
|
626
|
+
? `<nav class="filters" aria-label="Event types">${filters}</nav><div class="table-wrap"><table class="log"><tbody>${shown
|
|
627
|
+
.map((e) => eventRow(e, e.ts.replace("T", " ").slice(0, 19)))
|
|
628
|
+
.join("")}</tbody></table></div>`
|
|
629
|
+
: '<p class="empty">No events in the last 7 days.</p>'}</section>`;
|
|
322
630
|
}
|
|
323
631
|
export function settingsIndex() {
|
|
324
|
-
return `<section class="panel"><h2>Settings
|
|
325
|
-
<
|
|
632
|
+
return `<section class="panel"><h2>Settings${info("Every key in homeostat.yaml, as forms. Changes are validated before anything is written, and comments in the file are kept.")}</h2>
|
|
633
|
+
<ul class="sections">${SECTIONS.map((s) => `<li><a href="/settings/${s.id}">${esc(s.title)}</a>${info(esc(s.intro))}</li>`).join("")}</ul></section>`;
|
|
326
634
|
}
|
|
327
635
|
function input(field, value) {
|
|
328
636
|
const name = esc(field.path.join("."));
|
|
@@ -351,14 +659,14 @@ function input(field, value) {
|
|
|
351
659
|
default:
|
|
352
660
|
control = `<input type="text" id="${id}" name="${name}" value="${esc(value ?? "")}">`;
|
|
353
661
|
}
|
|
354
|
-
return `<div class="field"><label for="${id}">${esc(field.label)}
|
|
662
|
+
return `<div class="field"><label for="${id}">${esc(field.label)}${field.help ? info(esc(field.help)) : ""}</label>${control}</div>`;
|
|
355
663
|
}
|
|
356
664
|
function rowEditor(section, config) {
|
|
357
665
|
if (section.rows === "team") {
|
|
358
666
|
const members = [...config.team.members, ...Array(3).fill(undefined)];
|
|
359
667
|
return `<div class="table-wrap"><table class="rows"><thead><tr><th>Id</th><th>Handle</th><th>Role</th><th>Areas</th><th title="The curator never reads this member's pull requests or review comments">Opted out</th><th>Remove</th></tr></thead><tbody>${members
|
|
360
668
|
.map((m, i) => `<tr><td><input type="text" name="member.${i}.id" value="${esc(m?.id ?? "")}" aria-label="Member id"></td><td><input type="text" name="member.${i}.handle" value="${esc(m?.handle ?? "")}" aria-label="Handle"></td><td><select name="member.${i}.role" aria-label="Role">${["junior", "member", "senior", "lead"].map((r) => `<option${(m?.role ?? "member") === r ? " selected" : ""}>${r}</option>`).join("")}</select></td><td><input type="text" name="member.${i}.areas" value="${esc(m?.areas.join(", ") ?? "")}" aria-label="Areas"></td><td><input type="checkbox" name="member.${i}.optOut"${m?.optOut ? " checked" : ""} aria-label="Opted out"></td><td>${m ? `<input type="checkbox" name="member.${i}.remove" aria-label="Remove ${esc(m.id)}">` : ""}</td></tr>`)
|
|
361
|
-
.join("")}</tbody></table></div><p class="muted">Blank rows add members. Areas are comma-separated
|
|
669
|
+
.join("")}</tbody></table></div><p class="muted">Adding rows${info("Blank rows add members. Areas are comma-separated.")}</p>`;
|
|
362
670
|
}
|
|
363
671
|
if (section.rows === "teams") {
|
|
364
672
|
const teams = [...Object.entries(config.teams), ...Array(2).fill(undefined)];
|
|
@@ -373,12 +681,12 @@ function rowEditor(section, config) {
|
|
|
373
681
|
cell(i, "repos", t?.repos.join(", ") ?? "", "Repos"),
|
|
374
682
|
`<td>${t ? `<input type="checkbox" name="teamrow.${i}.remove" aria-label="Remove ${esc(name)}">` : ""}</td></tr>`,
|
|
375
683
|
].join(""))
|
|
376
|
-
.join("")}</tbody></table></div><p class="muted">Blank rows add teams. Lists are comma-separated; the handle is an @org/team
|
|
684
|
+
.join("")}</tbody></table></div><p class="muted">Adding rows${info("Blank rows add teams. Lists are comma-separated; the handle is an @org/team.")}</p>`;
|
|
377
685
|
}
|
|
378
686
|
const areas = [...Object.entries(config.areas), ...Array(3).fill(undefined)];
|
|
379
687
|
return `<div class="table-wrap"><table class="rows"><thead><tr><th>Area</th><th>Path globs</th><th>Remove</th></tr></thead><tbody>${areas
|
|
380
688
|
.map((a, i) => `<tr><td><input type="text" name="area.${i}.name" value="${esc(a?.[0] ?? "")}" aria-label="Area name"></td><td><input type="text" name="area.${i}.globs" value="${esc(a?.[1]?.join(", ") ?? "")}" aria-label="Path globs"></td><td>${a ? `<input type="checkbox" name="area.${i}.remove" aria-label="Remove ${esc(a[0])}">` : ""}</td></tr>`)
|
|
381
|
-
.join("")}</tbody></table></div><p class="muted">Blank rows add areas. Globs are comma-separated
|
|
689
|
+
.join("")}</tbody></table></div><p class="muted">Adding rows${info("Blank rows add areas. Globs are comma-separated.")}</p>`;
|
|
382
690
|
}
|
|
383
691
|
export function settingsPage(section, config, token, notice) {
|
|
384
692
|
const banner = notice?.saved
|
|
@@ -389,7 +697,7 @@ export function settingsPage(section, config, token, notice) {
|
|
|
389
697
|
const body = section.rows
|
|
390
698
|
? rowEditor(section, config)
|
|
391
699
|
: (section.fields ?? []).map((f) => input(f, currentValue(config, f))).join("");
|
|
392
|
-
return `<section class="panel"><p
|
|
700
|
+
return `<section class="panel"><p><a href="/settings">Back to settings</a></p><h2>${esc(section.title)}${info(esc(section.intro))}</h2>${banner}
|
|
393
701
|
<form class="settings" method="post" action="/settings/${section.id}"><input type="hidden" name="_token" value="${esc(token)}">${body}<button type="submit">Save</button></form></section>`;
|
|
394
702
|
}
|
|
395
703
|
export function messagePage(title, text) {
|