seisin 0.1.1 → 0.2.0
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/README.md +225 -339
- package/package.json +2 -2
- package/src/cli.js +15 -2
- package/src/commands/init.js +1 -1
- package/src/commands/requests.js +1 -1
- package/src/commands/run.js +69 -1
- package/src/commands/walls.js +24 -0
- package/src/config.js +160 -3
- package/src/hook.js +33 -2
- package/src/index.js +12 -0
- package/src/inspect.js +154 -4
- package/src/keys.js +483 -0
- package/src/mcp.js +59 -1
- package/src/nested.js +86 -0
- package/src/render.js +34 -1
- package/src/serve.js +137 -3
- package/src/srt.js +48 -1
- package/src/walls.js +170 -0
- package/ui/index.html +367 -31
package/ui/index.html
CHANGED
|
@@ -86,7 +86,7 @@ button.btn:hover{background:var(--hover)}
|
|
|
86
86
|
button.btn.warn{border-color:var(--amber);color:var(--amber)}
|
|
87
87
|
button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
|
88
88
|
|
|
89
|
-
.cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(
|
|
89
|
+
.cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
|
|
90
90
|
.card{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--line2);
|
|
91
91
|
border-radius:8px;padding:14px 16px;min-height:96px;display:flex;flex-direction:column}
|
|
92
92
|
.card.g{border-left-color:var(--green)} .card.a{border-left-color:var(--amber)} .card.b{border-left-color:var(--blue)}
|
|
@@ -94,6 +94,51 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
|
|
|
94
94
|
.card .n{font-size:28px;font-weight:600;line-height:1;margin-top:12px;font-variant-numeric:tabular-nums;min-width:2.2ch}
|
|
95
95
|
.card .s{font-size:11px;color:var(--mid);margin-top:5px;min-height:16px}
|
|
96
96
|
.card.g .n{color:var(--green)} .card.a .n{color:var(--amber)} .card.b .n{color:var(--blue)}
|
|
97
|
+
.card.kpi{text-decoration:none;color:inherit;transition:border-color .12s,transform .12s}
|
|
98
|
+
.card.kpi:hover{border-color:var(--line2)}
|
|
99
|
+
.card.kpi:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
|
100
|
+
/* Only when it is not zero. An indicator that shouts at rest is one people
|
|
101
|
+
learn to ignore, and these two are the ones that mean "do something". */
|
|
102
|
+
.card.kpi.live{border-left-color:var(--blue)} .card.kpi.live .n{color:var(--blue)}
|
|
103
|
+
.card.kpi.alarm{border-left-color:var(--amber)} .card.kpi.alarm .n{color:var(--amber)}
|
|
104
|
+
.card.kpi.calm .n{color:var(--dim)}
|
|
105
|
+
/* Left border follows the state too. A card that is amber at zero has cried
|
|
106
|
+
wolf before you read the number. */
|
|
107
|
+
.card.kpi{border-left-color:var(--line2)}
|
|
108
|
+
|
|
109
|
+
/* The shape of the day. A hundred identical rows is not a reading, so each cause gets a
|
|
110
|
+
bar proportional to the others and the eye does the arithmetic. */
|
|
111
|
+
.lede{font-size:14px;line-height:1.55;margin:0 0 16px;color:var(--fg)}
|
|
112
|
+
.lede b{font-weight:600}
|
|
113
|
+
.lede .q{color:var(--mid)}
|
|
114
|
+
.lede code{font-family:var(--mono);color:var(--fg);font-size:13px}
|
|
115
|
+
.pad{padding:16px}
|
|
116
|
+
.bars{display:flex;flex-direction:column;gap:10px}
|
|
117
|
+
.bar{display:grid;grid-template-columns:1fr 132px;gap:14px;align-items:baseline}
|
|
118
|
+
.bar .lbl{min-width:0;font-size:12px}
|
|
119
|
+
.bar .p{font-family:var(--mono);color:var(--fg);overflow-wrap:anywhere}
|
|
120
|
+
.bar .who{color:var(--mid);font-size:11px;margin-top:2px}
|
|
121
|
+
.bar .t{height:6px;border-radius:3px;background:var(--line);overflow:hidden;margin-top:6px}
|
|
122
|
+
.bar .f{height:100%;border-radius:3px;background:var(--blue);display:block}
|
|
123
|
+
.bar.cold .f{background:var(--line2)}
|
|
124
|
+
.bar .num{text-align:right;font-variant-numeric:tabular-nums;font-size:12px;color:var(--fg)}
|
|
125
|
+
.bar .num s{display:block;color:var(--mid);font-size:11px;text-decoration:none;margin-top:2px}
|
|
126
|
+
.bar.cold .p,.bar.cold .num{color:var(--mid)}
|
|
127
|
+
.fam{padding:14px 0;border-top:1px solid var(--line)}
|
|
128
|
+
.fam:first-child{border-top:0;padding-top:0}
|
|
129
|
+
.fam-h{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
|
|
130
|
+
.fam-h code{font-family:var(--mono);font-size:13px;color:var(--fg);font-weight:600}
|
|
131
|
+
.fam-h .num{font-variant-numeric:tabular-nums;font-size:13px;color:var(--fg);white-space:nowrap}
|
|
132
|
+
.fam-h .num s{color:var(--mid);text-decoration:none;font-size:11px;margin-left:4px}
|
|
133
|
+
.fam .t{height:5px;border-radius:3px;background:var(--line);overflow:hidden;margin:8px 0 6px}
|
|
134
|
+
.fam .f{display:block;height:100%;border-radius:3px;background:var(--blue)}
|
|
135
|
+
.fam .who{font-size:11px;color:var(--mid)}
|
|
136
|
+
.places{margin:8px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}
|
|
137
|
+
.places li{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
|
|
138
|
+
font-family:var(--mono);font-size:11px;color:var(--mid)}
|
|
139
|
+
.places li span{overflow-wrap:anywhere}
|
|
140
|
+
.places li em{font-family:inherit;font-style:normal;color:var(--dim)}
|
|
141
|
+
.places li b{color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
97
142
|
|
|
98
143
|
.box{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
|
99
144
|
.box>header{padding:10px 14px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;
|
|
@@ -154,7 +199,7 @@ pre .c{color:var(--dim)} pre .k{color:var(--blue)} pre .d{color:var(--amber)} pr
|
|
|
154
199
|
.ev .who em{font-style:normal;color:var(--blue);font-weight:600}
|
|
155
200
|
.ev .when{font-size:11px;color:var(--dim);flex:0 0 auto;font-variant-numeric:tabular-nums}
|
|
156
201
|
/* Which writer saw it. The hook reports an attempt and can be talked around; the kernel
|
|
157
|
-
reports a
|
|
202
|
+
reports a denial and cannot. When the two disagree about a run the reader needs to know
|
|
158
203
|
which line carries more weight, so the one that does is the one that is marked — a small
|
|
159
204
|
word in the margin, not a column. Lines without the field render exactly as before. */
|
|
160
205
|
.ev .src{font:10px/1.6 var(--mono);color:var(--dim);flex:0 0 auto;padding:0 5px;
|
|
@@ -201,6 +246,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
201
246
|
/* What a view says when it has nothing to show. Not a shrug: the two reasons a log is empty
|
|
202
247
|
need different answers, so it names them. */
|
|
203
248
|
.blank{margin:0;padding:34px 16px;color:var(--fg);font-size:13px;line-height:1.7;text-align:center}
|
|
249
|
+
.blank.quiet{padding:16px;font-size:12px;color:var(--mid)}
|
|
250
|
+
.blank.quiet span{display:inline}
|
|
204
251
|
.blank span{color:var(--mid);display:block;margin-top:6px;font-size:12px;
|
|
205
252
|
max-width:58ch;margin-inline:auto;text-wrap:pretty}
|
|
206
253
|
.blank code{font-family:var(--mono);font-size:11.5px;background:var(--hover);
|
|
@@ -248,8 +295,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
248
295
|
</div>
|
|
249
296
|
<div class="list">
|
|
250
297
|
<div class="cap">Policy</div>
|
|
251
|
-
<a class="on" href="#
|
|
252
|
-
<a href="#
|
|
298
|
+
<a class="on" href="#requests" id="nav-req" aria-current="page"><i>⚖</i>Waiting on you<span class="pin" id="req-pin"></span></a>
|
|
299
|
+
<a href="#denied"><i>⊘</i>Denied<span class="pin" id="dn-pin"></span></a>
|
|
300
|
+
<a href="#walls"><i>▦</i>Walls<span class="pin" id="wl-pin"></span></a>
|
|
301
|
+
<a href="#policy"><i>⚷</i>Roles<span class="pin" id="pin"></span></a>
|
|
253
302
|
<a href="#activity"><i>◷</i>Activity</a>
|
|
254
303
|
<a href="#configfile"><i>⚙</i>Config file</a>
|
|
255
304
|
<div class="cap">Docs</div>
|
|
@@ -270,15 +319,32 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
270
319
|
<button class="btn warn" id="kill" type="button">Unsandbox everything</button>
|
|
271
320
|
</div>
|
|
272
321
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
322
|
+
<!-- OUTSIDE the views, so the same five numbers are on every screen.
|
|
323
|
+
They used to live inside Roles, which meant the only live one —
|
|
324
|
+
denials — was invisible from the three other pages, and four of the
|
|
325
|
+
five were constants: 31 roles, 31 sandboxed, 0 unsandboxed, 31 with no
|
|
326
|
+
keys. A number that reads the same every day is not an indicator, it
|
|
327
|
+
is a caption, and putting it beside a live one teaches people to stop
|
|
328
|
+
reading the row.
|
|
329
|
+
|
|
330
|
+
Each one is a link to the screen that explains it. A number you cannot
|
|
331
|
+
act on is decoration; a number that takes you to the thing it counts
|
|
332
|
+
is the top of a path. -->
|
|
333
|
+
<div class="cards" id="kpis">
|
|
334
|
+
<a class="card kpi" href="#requests" id="k-wait">
|
|
335
|
+
<h2>waiting on you</h2><div class="n" id="c-wait">0</div><div class="s" id="c-wait-s">nothing to decide</div></a>
|
|
336
|
+
<a class="card kpi" href="#policy" id="k-open">
|
|
337
|
+
<h2>unsandboxed</h2><div class="n" id="c-off">0</div><div class="s" id="c-off-s">none are open</div></a>
|
|
338
|
+
<a class="card kpi" href="#denied" id="k-ref">
|
|
339
|
+
<h2>denied</h2><div class="n" id="c-blocks">0</div><div class="s" id="c-blocks-s">in this window</div></a>
|
|
340
|
+
<a class="card kpi" href="#causes" id="k-conc">
|
|
341
|
+
<h2>one cause</h2><div class="n" id="c-conc">—</div><div class="s" id="c-conc-s">of all denials</div></a>
|
|
342
|
+
<a class="card kpi" href="#walls" id="k-retry">
|
|
343
|
+
<h2>spent retrying</h2><div class="n" id="c-retry">0</div><div class="s" id="c-retry-s">calls on known walls</div></a>
|
|
280
344
|
</div>
|
|
281
345
|
|
|
346
|
+
<section data-view="roles">
|
|
347
|
+
|
|
282
348
|
<div class="box">
|
|
283
349
|
<header id="policy">
|
|
284
350
|
<h2>Policy</h2>
|
|
@@ -312,16 +378,65 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
312
378
|
</div>
|
|
313
379
|
</section>
|
|
314
380
|
|
|
381
|
+
<!-- One view per KPI. A number you can click is the top of a path; a number
|
|
382
|
+
you cannot is decoration, and the five up there all pointed at two
|
|
383
|
+
pages. Each of these is the detail of exactly one of them. -->
|
|
384
|
+
|
|
315
385
|
<section data-view="requests" hidden>
|
|
316
386
|
<div class="box" id="requests">
|
|
317
387
|
<header>
|
|
318
|
-
<h2>
|
|
319
|
-
<span class="note">
|
|
388
|
+
<h2>Pending decisions</h2>
|
|
389
|
+
<span class="note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
|
|
320
390
|
</header>
|
|
321
391
|
<div id="reqs"></div>
|
|
322
392
|
</div>
|
|
323
393
|
</section>
|
|
324
394
|
|
|
395
|
+
<!-- KPI: denied. Grouped by WHAT was denied, never by who asked — per role
|
|
396
|
+
it is the same list a hundred times over. -->
|
|
397
|
+
<section data-view="denied" hidden>
|
|
398
|
+
<div class="box">
|
|
399
|
+
<header>
|
|
400
|
+
<h2>Denied, by path</h2>
|
|
401
|
+
<span class="note" id="dn-window"></span>
|
|
402
|
+
</header>
|
|
403
|
+
<div class="pad">
|
|
404
|
+
<p class="lede" id="ca-lede"></p>
|
|
405
|
+
<div class="bars" id="ca-bars"></div>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</section>
|
|
409
|
+
|
|
410
|
+
<!-- KPI: one cause. The same denials one level coarser — by the name at the
|
|
411
|
+
end of the path. This is the cut that says whether a day was a tooling
|
|
412
|
+
problem or a territory one, and by path alone it reads backwards. -->
|
|
413
|
+
<section data-view="causes" hidden>
|
|
414
|
+
<div class="box">
|
|
415
|
+
<header>
|
|
416
|
+
<h2>Denied, by name</h2>
|
|
417
|
+
<span class="note">the same denials, grouped one level coarser</span>
|
|
418
|
+
</header>
|
|
419
|
+
<div class="pad">
|
|
420
|
+
<p class="lede" id="fa-lede"></p>
|
|
421
|
+
<div id="fa-list"></div>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
</section>
|
|
425
|
+
|
|
426
|
+
<!-- KPI: spent retrying. -->
|
|
427
|
+
<section data-view="walls" hidden>
|
|
428
|
+
<div class="box">
|
|
429
|
+
<header>
|
|
430
|
+
<h2>Walls</h2>
|
|
431
|
+
<span class="note">denied twice or more, and the policy still denies it</span>
|
|
432
|
+
</header>
|
|
433
|
+
<div class="pad">
|
|
434
|
+
<p class="lede" id="wl-lede"></p>
|
|
435
|
+
<div id="wl-list"></div>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</section>
|
|
439
|
+
|
|
325
440
|
<section data-view="activity" hidden>
|
|
326
441
|
<div class="box">
|
|
327
442
|
<header><h2>Activity</h2><span class="note">every decision, with its owner</span></header>
|
|
@@ -526,12 +641,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
526
641
|
|
|
527
642
|
var on=0,off=0,nok=0,bl=0;
|
|
528
643
|
ROLES.forEach(function(r){r.sandbox?on++:off++; if(!r.keys.length)nok++; bl+=r.blocks;});
|
|
529
|
-
|
|
530
|
-
document.getElementById("c-on").textContent=on;
|
|
531
|
-
document.getElementById("c-off").textContent=off;
|
|
532
|
-
document.getElementById("c-off-s").textContent=off?"nothing enforces these":"none are open";
|
|
533
|
-
document.getElementById("c-nokeys").textContent=nok;
|
|
534
|
-
document.getElementById("c-blocks").textContent=bl;
|
|
644
|
+
drawKpis();
|
|
535
645
|
var pin=document.getElementById("pin");
|
|
536
646
|
if(off){pin.textContent=off;pin.hidden=false;}else{pin.hidden=true;}
|
|
537
647
|
var k=document.getElementById("kill");
|
|
@@ -650,7 +760,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
650
760
|
// Only the kernel's own lines get the mark. A hook line and an older line look the
|
|
651
761
|
// same as they always did — the absence is the default, not a third state.
|
|
652
762
|
var src=e.source==="kernel"
|
|
653
|
-
? '<span class="src" title="Recorded from the kernel: what the OS actually
|
|
763
|
+
? '<span class="src" title="Recorded from the kernel: what the OS actually denied, '+
|
|
654
764
|
'whether or not the hook saw the attempt. This line cannot be talked around.">kernel</span>'
|
|
655
765
|
: "";
|
|
656
766
|
d.innerHTML="<b>"+label+'</b><span class="body"><span class="what">'+line+"</span>"+who+
|
|
@@ -679,6 +789,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
679
789
|
|
|
680
790
|
var REQS = [];
|
|
681
791
|
var ONDISK = null; // the file on disk, when there is one
|
|
792
|
+
var CAUSES = null; // this window's denials, grouped by what was denied
|
|
793
|
+
var WALLS = {}; // per role, what it keeps being denied and still would be
|
|
682
794
|
|
|
683
795
|
/**
|
|
684
796
|
* The queue, as something a person acts on.
|
|
@@ -686,6 +798,200 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
686
798
|
* Hidden entirely when empty rather than shown as an empty table: the point
|
|
687
799
|
* of this panel is to be noticed on the days it has something in it.
|
|
688
800
|
*/
|
|
801
|
+
/**
|
|
802
|
+
* The shape of the window, grouped by cause.
|
|
803
|
+
*
|
|
804
|
+
* The reading comes first and the bars second, because the number that
|
|
805
|
+
* matters is almost never "how many" — it is "how much of it is one thing".
|
|
806
|
+
* A day where three quarters of the denials are a single lock file is a
|
|
807
|
+
* day where nobody disputed any territory, and that sentence is the whole
|
|
808
|
+
* value of grouping. It is written out rather than left for the reader to
|
|
809
|
+
* infer from twelve bars.
|
|
810
|
+
*/
|
|
811
|
+
function drawCauses(){
|
|
812
|
+
var f = CAUSES;
|
|
813
|
+
var lede = document.getElementById("ca-lede");
|
|
814
|
+
if (!f || !f.total) {
|
|
815
|
+
if (lede) lede.textContent = "Nothing was denied in this window.";
|
|
816
|
+
var bars = document.getElementById("ca-bars"); if (bars) bars.innerHTML = "";
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
var top = f.causes[0];
|
|
820
|
+
var fam = (f.families || [])[0];
|
|
821
|
+
var famShare = fam ? Math.round(fam.share * 100) : 0;
|
|
822
|
+
var lede = "<b>" + f.total.toLocaleString() + "</b> denial" + (f.total === 1 ? "" : "s") +
|
|
823
|
+
" in this window, over <b>" + (f.distinct || f.causes.length).toLocaleString() +
|
|
824
|
+
"</b> distinct path" + ((f.distinct || f.causes.length) === 1 ? "" : "s") + ". ";
|
|
825
|
+
// The reading has to be taken at the level of the NAME, not the path, or it comes out
|
|
826
|
+
// backwards. Measured here: six of the top seven causes were the same lock file in six
|
|
827
|
+
// different repositories and no single path passed 17% \u2014 so "no cause dominates" was
|
|
828
|
+
// what the arithmetic said and the opposite of the truth. One KIND of thing at that scale
|
|
829
|
+
// is a tooling problem with a mechanical fix; the same volume spread across unrelated
|
|
830
|
+
// paths is a territory question and needs a person. They take opposite responses, which
|
|
831
|
+
// is why this sentence is written out instead of left to be inferred from twelve bars.
|
|
832
|
+
if (fam && famShare >= 40 && fam.paths > 1)
|
|
833
|
+
lede += "<span class=\"q\"><b>" + fam.times.toLocaleString() + "</b> of them \u2014 <b>" +
|
|
834
|
+
famShare + "%</b> \u2014 are the same name in " + fam.paths + " places: " +
|
|
835
|
+
"<code>" + esc(fam.name) + "</code>. One kind of thing at that scale is a " +
|
|
836
|
+
"tooling problem with a mechanical fix, not something to grant.</span>";
|
|
837
|
+
else if (fam && famShare >= 40)
|
|
838
|
+
lede += "<span class=\"q\"><b>" + famShare + "%</b> of it is a single path. That is " +
|
|
839
|
+
"usually mechanical rather than something to grant.</span>";
|
|
840
|
+
else
|
|
841
|
+
lede += "<span class=\"q\">No one kind of thing dominates, which is what a real boundary " +
|
|
842
|
+
"question looks like rather than a tooling one.</span>";
|
|
843
|
+
/* The half a grep cannot do, because it needs the policy: how many of these
|
|
844
|
+
are on paths nobody claims. Those are the ones no grant resolves until a
|
|
845
|
+
person decides who owns them — a decision, not a count. Added after a
|
|
846
|
+
field review pointed out that the percentage above was the console
|
|
847
|
+
reading the log back to somebody who had already read it. */
|
|
848
|
+
if (f.unowned)
|
|
849
|
+
lede += " <b>" + f.unowned + "</b> of the " + (f.distinct || f.causes.length).toLocaleString() +
|
|
850
|
+
" are on paths <b>no role owns</b> \u2014 a grant cannot settle those until " +
|
|
851
|
+
"somebody claims them.";
|
|
852
|
+
document.getElementById("ca-lede").innerHTML = lede;
|
|
853
|
+
var win = document.getElementById("dn-window");
|
|
854
|
+
if (win) win.textContent = f.total.toLocaleString() + " denials \u00b7 " +
|
|
855
|
+
(f.distinct || f.causes.length).toLocaleString() + " distinct paths";
|
|
856
|
+
|
|
857
|
+
var max = top.times || 1;
|
|
858
|
+
document.getElementById("ca-bars").innerHTML = f.causes.map(function(c){
|
|
859
|
+
var w = Math.max(2, Math.round((c.times / max) * 100));
|
|
860
|
+
// A cause the policy no longer denies is history, not live. It is
|
|
861
|
+
// shown, greyed, rather than dropped: it explains a number somebody may
|
|
862
|
+
// remember from yesterday instead of silently changing it.
|
|
863
|
+
var cold = c.stillDeclined === 0;
|
|
864
|
+
var who = c.roles.length === 1 ? c.roles[0] : c.roles.length + " roles";
|
|
865
|
+
return '<div class="bar' + (cold ? ' cold' : '') + '">' +
|
|
866
|
+
'<div class="lbl"><div class="p">' + esc(c.action) + ' ' + esc(c.target) + '</div>' +
|
|
867
|
+
'<div class="who">' + esc(who) +
|
|
868
|
+
(c.owners && c.owners.length ? ' · owned by ' + esc(c.owners.join(", ")) : ' · owned by nobody') +
|
|
869
|
+
(cold ? ' · <b>granted since</b>' : '') + '</div>' +
|
|
870
|
+
'<div class="t"><span class="f" style="width:' + w + '%"></span></div></div>' +
|
|
871
|
+
'<div class="num">' + c.times.toLocaleString() +
|
|
872
|
+
'<s>' + (c.share * 100).toFixed(1) + '%</s></div>' +
|
|
873
|
+
'</div>';
|
|
874
|
+
}).join("");
|
|
875
|
+
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Denied, by name — the cut that says what kind of day it was. */
|
|
879
|
+
function drawFamilies(){
|
|
880
|
+
var f = CAUSES; if (!f) return;
|
|
881
|
+
var fams = f.families || [];
|
|
882
|
+
var top = fams[0];
|
|
883
|
+
document.getElementById("fa-lede").innerHTML = !top ? "Nothing was denied in this window."
|
|
884
|
+
: "<b>" + Math.round(top.share * 100) + "%</b> of " + f.total.toLocaleString() +
|
|
885
|
+
" denials is <code>" + esc(top.name) + "</code>, across <b>" + top.paths + "</b> path" +
|
|
886
|
+
(top.paths === 1 ? "" : "s") + "." +
|
|
887
|
+
(top.share >= 0.4 && top.paths > 1
|
|
888
|
+
? ' <span class="q">One name at that scale is usually mechanical. Fixing the tool ' +
|
|
889
|
+
'removes the denials; granting the paths only hides them.</span>'
|
|
890
|
+
: ' <span class="q">No single name dominates \u2014 these are separate questions, ' +
|
|
891
|
+
'not one problem.</span>');
|
|
892
|
+
|
|
893
|
+
document.getElementById("fa-list").innerHTML = fams.map(function(x){
|
|
894
|
+
var w = Math.max(2, Math.round((x.times / (top.times || 1)) * 100));
|
|
895
|
+
return '<div class="fam">' +
|
|
896
|
+
'<div class="fam-h"><code>' + esc(x.name) + '</code>' +
|
|
897
|
+
'<span class="num">' + x.times.toLocaleString() + ' <s>' + (x.share*100).toFixed(1) + '%</s></span></div>' +
|
|
898
|
+
'<div class="t"><span class="f" style="width:' + w + '%"></span></div>' +
|
|
899
|
+
'<div class="who">' + x.paths + ' path' + (x.paths===1?'':'s') + ' \u00b7 ' +
|
|
900
|
+
x.roles.length + ' role' + (x.roles.length===1?'':'s') + ': ' + esc(x.roles.join(", ")) + '</div>' +
|
|
901
|
+
'<ul class="places">' + x.where.map(function(p){
|
|
902
|
+
return '<li><span>' + esc(p.target) + '</span><b>' + p.times.toLocaleString() + '</b></li>';
|
|
903
|
+
}).join("") + '</ul></div>';
|
|
904
|
+
}).join("");
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Walls — per role, what it keeps hitting and what that cost. */
|
|
908
|
+
function drawWalls(){
|
|
909
|
+
var rows = Object.keys(WALLS || {}).map(function(r){
|
|
910
|
+
var w = WALLS[r];
|
|
911
|
+
return { role: r, walls: w, spent: w.reduce(function(n,x){ return n + x.times - 1; }, 0) };
|
|
912
|
+
}).sort(function(a,b){ return b.spent - a.spent; });
|
|
913
|
+
var total = rows.reduce(function(n,x){ return n + x.spent; }, 0);
|
|
914
|
+
|
|
915
|
+
document.getElementById("wl-lede").innerHTML = !rows.length
|
|
916
|
+
? "No role is repeating a denial it has already been given."
|
|
917
|
+
: "<b>" + total.toLocaleString() + "</b> call" + (total===1?"":"s") + " across <b>" +
|
|
918
|
+
rows.length + "</b> role" + (rows.length===1?"":"s") +
|
|
919
|
+
' went into retrying something already denied. <span class="q">A denial is said once, ' +
|
|
920
|
+
'mid-turn. Nothing carries it forward, so it gets tried again.</span>';
|
|
921
|
+
|
|
922
|
+
document.getElementById("wl-list").innerHTML = rows.map(function(x){
|
|
923
|
+
return '<div class="fam">' +
|
|
924
|
+
'<div class="fam-h"><code>' + esc(x.role) + '</code>' +
|
|
925
|
+
'<span class="num">' + x.spent.toLocaleString() + ' <s>calls retried</s></span></div>' +
|
|
926
|
+
'<ul class="places">' + x.walls.map(function(w){
|
|
927
|
+
return '<li><span>' + esc(w.action) + ' ' + esc(w.target) + '<br>' +
|
|
928
|
+
'<em>' + esc(w.reason) + '</em></span><b>' + w.times + '\u00d7</b></li>';
|
|
929
|
+
}).join("") + '</ul></div>';
|
|
930
|
+
}).join("");
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** A count beside a nav item, or nothing. Zero is not news. */
|
|
934
|
+
function pill(id, n){
|
|
935
|
+
var el = document.getElementById(id);
|
|
936
|
+
if (!el) return;
|
|
937
|
+
el.textContent = n ? (n > 999 ? Math.round(n/1000) + "k" : String(n)) : "";
|
|
938
|
+
el.hidden = !n;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* The five numbers, on every screen.
|
|
943
|
+
*
|
|
944
|
+
* Chosen by one rule: a KPI has to be something that can change and that you
|
|
945
|
+
* would do something about. The row this replaces failed both — four of its
|
|
946
|
+
* five read 31, 31, 0 and 31 every single day — and the one that moved was
|
|
947
|
+
* last and the same size as the rest.
|
|
948
|
+
*
|
|
949
|
+
* Emphasis is conditional, never constant. At zero they go quiet and say so
|
|
950
|
+
* in words, because "nothing to decide" is a state and an indicator that
|
|
951
|
+
* shouts at rest is one people stop seeing.
|
|
952
|
+
*
|
|
953
|
+
* Every value is read from the globals rather than passed in, so any refresh
|
|
954
|
+
* can call it — the queue refreshes on its own, and passing the role counts
|
|
955
|
+
* in once left the row stale on the number that moves most.
|
|
956
|
+
*/
|
|
957
|
+
function drawKpis(){
|
|
958
|
+
var f = CAUSES || { total: 0, families: [], distinct: 0, causes: [] };
|
|
959
|
+
var waiting = REQS.length;
|
|
960
|
+
var retry = Object.keys(WALLS || {}).reduce(function(n, r){
|
|
961
|
+
return n + WALLS[r].reduce(function(m, w){ return m + w.times - 1; }, 0);
|
|
962
|
+
}, 0);
|
|
963
|
+
var fam = f.families && f.families[0];
|
|
964
|
+
var off = (ROLES || []).filter(function(r){ return !r.sandbox; }).length;
|
|
965
|
+
|
|
966
|
+
function set(id, cls, value, sub){
|
|
967
|
+
var el = document.getElementById(id);
|
|
968
|
+
if (!el) return;
|
|
969
|
+
el.classList.remove("live","alarm","calm");
|
|
970
|
+
if (cls) el.classList.add(cls);
|
|
971
|
+
el.querySelector(".n").textContent = value;
|
|
972
|
+
el.querySelector(".s").textContent = sub;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
set("k-wait", waiting ? "live" : "calm", waiting,
|
|
976
|
+
waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
|
|
977
|
+
// The only number here that is an emergency, and it is almost always zero.
|
|
978
|
+
set("k-open", off ? "alarm" : "calm", off,
|
|
979
|
+
off ? (off === 1 ? "role nothing is enforcing" : "roles nothing is enforcing") : "every role confined");
|
|
980
|
+
set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
|
|
981
|
+
f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct paths" : "nothing was denied");
|
|
982
|
+
// The shape, not the size: one name at 74% is a tooling problem, the same
|
|
983
|
+
// volume spread thin is a territory question, so the caption carries the name.
|
|
984
|
+
set("k-conc", fam && fam.share >= 0.4 ? "live" : "calm",
|
|
985
|
+
fam ? Math.round(fam.share * 100) + "%" : "\u2014",
|
|
986
|
+
fam ? "is " + fam.name : "of all denials");
|
|
987
|
+
// The only one that is money rather than state.
|
|
988
|
+
set("k-retry", retry ? "live" : "calm", retry.toLocaleString(),
|
|
989
|
+
retry ? "calls on walls already denied" : "nothing retried");
|
|
990
|
+
|
|
991
|
+
pill("dn-pin", f.total);
|
|
992
|
+
pill("wl-pin", Object.keys(WALLS || {}).length);
|
|
993
|
+
}
|
|
994
|
+
|
|
689
995
|
function drawReqs(){
|
|
690
996
|
var box = document.getElementById("requests");
|
|
691
997
|
var pin = document.getElementById("req-pin");
|
|
@@ -696,10 +1002,12 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
696
1002
|
// a page with a heading and nothing under it. It stays, and says what would fill it.
|
|
697
1003
|
box.hidden = false;
|
|
698
1004
|
if (!REQS.length) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
1005
|
+
// Compact when this is the landing view. The tall empty state was right
|
|
1006
|
+
// when Requests was a page of its own — a heading with nothing under it
|
|
1007
|
+
// reads as broken. At the top of the front page it is 180px of nothing
|
|
1008
|
+
// standing between you and the only content there is.
|
|
1009
|
+
host.innerHTML = '<p class="blank quiet">No pending decisions. ' +
|
|
1010
|
+
'<span>Agents land here when they ask for a path the policy denies them.</span></p>';
|
|
703
1011
|
return;
|
|
704
1012
|
}
|
|
705
1013
|
|
|
@@ -715,7 +1023,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
715
1023
|
'<div class="act">' +
|
|
716
1024
|
'<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
|
|
717
1025
|
'<button class="btn" data-n="' + (i+1) + '" data-d="granted" type="button">Approve</button>' +
|
|
718
|
-
'<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">
|
|
1026
|
+
'<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">Decline</button>' +
|
|
719
1027
|
"</div>" +
|
|
720
1028
|
"</div>";
|
|
721
1029
|
}).join("");
|
|
@@ -743,7 +1051,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
743
1051
|
body: JSON.stringify({ number: n, decision: btn.dataset.d, reason: why })
|
|
744
1052
|
});
|
|
745
1053
|
var out = await r.json();
|
|
746
|
-
if (!r.ok) throw new Error(out.error || "
|
|
1054
|
+
if (!r.ok) throw new Error(out.error || "denied");
|
|
747
1055
|
// Re-read rather than patch: the grant rewrote seisin.toml, so the
|
|
748
1056
|
// territory on screen is stale too, not just the queue.
|
|
749
1057
|
await reload();
|
|
@@ -754,7 +1062,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
754
1062
|
}
|
|
755
1063
|
}
|
|
756
1064
|
|
|
757
|
-
|
|
1065
|
+
/**
|
|
1066
|
+
* One view failing must not take the others down.
|
|
1067
|
+
*
|
|
1068
|
+
* Measured the hard way: `drawCauses` kept a reference to a box that had moved
|
|
1069
|
+
* to its own page, threw on the first line, and every number on the screen —
|
|
1070
|
+
* including the five that have nothing to do with it — rendered as zero. A
|
|
1071
|
+
* console whose KPIs read zero because of a typo is worse than one that is
|
|
1072
|
+
* down, because zero is a plausible answer.
|
|
1073
|
+
*/
|
|
1074
|
+
function draw(){
|
|
1075
|
+
[drawRows, drawCfg, drawToml, drawLog, drawReqs, drawCauses, drawFamilies, drawWalls]
|
|
1076
|
+
.forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
|
|
1077
|
+
}
|
|
758
1078
|
|
|
759
1079
|
/**
|
|
760
1080
|
* The page works two ways on purpose.
|
|
@@ -791,6 +1111,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
791
1111
|
}));
|
|
792
1112
|
LOG = fromServer(s.log);
|
|
793
1113
|
REQS = s.requests || [];
|
|
1114
|
+
CAUSES = s.causes || null;
|
|
1115
|
+
WALLS = s.walls || {};
|
|
794
1116
|
ONDISK = s.toml || null;
|
|
795
1117
|
// The demo selection does not survive the live load. `sel` starts at "frontend", a role
|
|
796
1118
|
// from the example policy, and a real repo has no reason to have one. `drawCfg` then
|
|
@@ -830,7 +1152,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
830
1152
|
policy: {view:"roles", title:"Roles",
|
|
831
1153
|
sub:"Which folders each agent writes, which keys it may read, and whether it runs sandboxed."},
|
|
832
1154
|
requests: {view:"requests", title:"Waiting on you",
|
|
833
|
-
sub:"
|
|
1155
|
+
sub:"Permissions an agent asked for and cannot have. Granting one edits your policy."},
|
|
1156
|
+
denied: {view:"denied", title:"Denied",
|
|
1157
|
+
sub:"Every denial in the window, grouped by the path it was about."},
|
|
1158
|
+
causes: {view:"causes", title:"Causes",
|
|
1159
|
+
sub:"The same denials by name, which is the cut that says what kind of day it was."},
|
|
1160
|
+
walls: {view:"walls", title:"Walls",
|
|
1161
|
+
sub:"What each role keeps being denied, and what retrying it cost."},
|
|
834
1162
|
activity: {view:"activity", title:"Activity",
|
|
835
1163
|
sub:"Every decision the runtime made, newest last, each with the role that owns the path."},
|
|
836
1164
|
configfile: {view:"config", title:"Config file",
|
|
@@ -838,7 +1166,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
838
1166
|
};
|
|
839
1167
|
|
|
840
1168
|
function show(route){
|
|
841
|
-
var v = VIEWS[route] || VIEWS.
|
|
1169
|
+
var v = VIEWS[route] || VIEWS.requests;
|
|
842
1170
|
document.querySelectorAll("main section[data-view]").forEach(function(s){
|
|
843
1171
|
s.hidden = s.getAttribute("data-view") !== v.view;
|
|
844
1172
|
});
|
|
@@ -858,7 +1186,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
858
1186
|
window.scrollTo(0, 0);
|
|
859
1187
|
}
|
|
860
1188
|
|
|
861
|
-
|
|
1189
|
+
/**
|
|
1190
|
+
* The landing view is what is waiting on you, not the policy table.
|
|
1191
|
+
*
|
|
1192
|
+
* It opened on Roles, which is reference: a wall of paths you consult when you
|
|
1193
|
+
* have a question, rendered before anybody asked one. What a person opens this
|
|
1194
|
+
* for is "does anything need me", and that has to be the first screen or it is
|
|
1195
|
+
* a screen nobody reaches. Reference is looked up; decisions are shown.
|
|
1196
|
+
*/
|
|
1197
|
+
function route(){ return (location.hash || "#requests").slice(1); }
|
|
862
1198
|
window.addEventListener("hashchange", function(){ show(route()); });
|
|
863
1199
|
show(route());
|
|
864
1200
|
|
|
@@ -900,7 +1236,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
900
1236
|
// the same way the log does: by polling. Re-rendered only when it changed,
|
|
901
1237
|
// so a reason someone is halfway through typing is not wiped every 2s.
|
|
902
1238
|
var fresh = JSON.stringify(s.requests || []);
|
|
903
|
-
if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); }
|
|
1239
|
+
if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); drawKpis(); }
|
|
904
1240
|
// Only the counters that come from the log are refreshed. Whatever the
|
|
905
1241
|
// reader has been editing on screen stays put — a page that resets your
|
|
906
1242
|
// work every two seconds is worse than one that does not update.
|