@cotal-ai/web 0.24.0 → 0.25.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/dist/web/app.js +179 -57
- package/dist/web/graph.html +2 -0
- package/dist/web/graph.js +104 -22
- package/dist/web/index.html +5 -0
- package/dist/web/snapshot.js +99 -0
- package/dist/web.d.ts +121 -9
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +372 -23
- package/dist/web.js.map +1 -1
- package/package.json +4 -3
package/dist/web/app.js
CHANGED
|
@@ -119,6 +119,37 @@ function setConn(live) {
|
|
|
119
119
|
el.querySelector(".t").textContent = live ? "live" : "disconnected";
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** Say WHICH sources are showing their last good value, and why. Visible, not a console line: the
|
|
123
|
+
* whole point of keeping the snapshot is that the reader knows they are looking at it. Cleared by
|
|
124
|
+
* the next refresh in which everything landed, which is what recovery looks like from here. */
|
|
125
|
+
/** What the marker is currently saying, so a source read OUTSIDE the poll can add to it without
|
|
126
|
+
* erasing the poll's own findings. `refresh()` sets the four polled sources; the open channel's
|
|
127
|
+
* history is read later, inside `select()`, and a bare `setStale([channel])` there would drop the
|
|
128
|
+
* roster/channels/dms/activity refusals from the same label. */
|
|
129
|
+
let staleNow = [];
|
|
130
|
+
function setStale(stale) {
|
|
131
|
+
staleNow = stale;
|
|
132
|
+
renderStale();
|
|
133
|
+
}
|
|
134
|
+
/** Mark ONE source, or clear it, leaving every other source's mark alone. A successful read clears
|
|
135
|
+
* its own mark and nobody else's, which is what makes recovery per source rather than all-or-nothing. */
|
|
136
|
+
function markStale(name, entry) {
|
|
137
|
+
const rest = staleNow.filter((s) => s.name !== name);
|
|
138
|
+
setStale(entry ? [...rest, entry] : rest);
|
|
139
|
+
}
|
|
140
|
+
function renderStale() {
|
|
141
|
+
const el = $("stale");
|
|
142
|
+
if (!el) return;
|
|
143
|
+
const label = window.COTAL_SNAPSHOT.staleLabel(staleNow);
|
|
144
|
+
el.hidden = !label;
|
|
145
|
+
// CLEARED, NOT JUST HIDDEN. Returning early on recovery left the previous label and its tooltip
|
|
146
|
+
// sitting in the element. Hidden text is invisible until something else unhides it, and then the
|
|
147
|
+
// reader is told a source is stale that recovered some time ago. Recovery has to erase the claim,
|
|
148
|
+
// not park it.
|
|
149
|
+
el.querySelector(".t").textContent = label;
|
|
150
|
+
el.title = staleNow.map((s) => `${s.name}: ${s.reason}`).join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
122
153
|
// ── Header: golden-signal tiles ───────────────────────────────────────────────
|
|
123
154
|
// Fifth tile is last-heartbeat freshness (Presence.ts), NOT blocked-duration — we cannot know
|
|
124
155
|
// how long someone has been waiting (no statusSince on the wire). Label it honestly.
|
|
@@ -790,6 +821,12 @@ function refreshDerived() {
|
|
|
790
821
|
}
|
|
791
822
|
|
|
792
823
|
let loadSeq = 0;
|
|
824
|
+
/** The channel `channelMsgs` currently holds, so a refused re-read of THE SAME channel can keep what
|
|
825
|
+
* is on screen. `selected` cannot answer this: `select()` assigns it before the clear below, so by
|
|
826
|
+
* then it already names the channel being opened rather than the one being displayed. Without this,
|
|
827
|
+
* retention would show the previous channel's messages under the new channel's name, which is worse
|
|
828
|
+
* than an empty view. */
|
|
829
|
+
let shownChannel = null;
|
|
793
830
|
/** The in-flight channel bootstrap: `{key, promise}`, so a second call for the same channel shares it. */
|
|
794
831
|
let selecting = null;
|
|
795
832
|
async function select(key) {
|
|
@@ -808,6 +845,9 @@ async function select(key) {
|
|
|
808
845
|
// because returning early is exactly what left the buffer undrained.
|
|
809
846
|
if (selecting && selecting.key === key) return selecting.promise;
|
|
810
847
|
const seq = ++loadSeq;
|
|
848
|
+
// HELD BEFORE THE CLEAR, and only when it belongs to THIS channel. A fresh selection must not
|
|
849
|
+
// inherit the last channel's messages, so a switch holds nothing and starts empty as before.
|
|
850
|
+
const held = shownChannel === key ? channelMsgs : [];
|
|
811
851
|
channelMsgs = [];
|
|
812
852
|
// ARMED BEFORE THE FETCH IS ISSUED, which is the whole ordering. Re-armed on every selection
|
|
813
853
|
// because each one is a fresh two-phase bootstrap: a new history read, and a live tap that is
|
|
@@ -818,15 +858,32 @@ async function select(key) {
|
|
|
818
858
|
let release;
|
|
819
859
|
selecting = { key, promise: new Promise((r) => (release = r)) };
|
|
820
860
|
renderCenter();
|
|
821
|
-
// Same reason as the activity feed: a failed history read
|
|
822
|
-
//
|
|
823
|
-
//
|
|
861
|
+
// Same reason as the activity feed: a failed history read must not leave the boundary unpassed
|
|
862
|
+
// and the frames of this channel held out of the view that was opened to look at them. What it
|
|
863
|
+
// is NOT any more is an empty batch: it is the last history that was actually read, so a refused
|
|
864
|
+
// poll on the open channel keeps what is on screen instead of emptying it.
|
|
824
865
|
let msgs = [];
|
|
866
|
+
let refused = null;
|
|
825
867
|
try {
|
|
826
|
-
|
|
868
|
+
// READ THROUGH THE SAME GATE AS EVERY OTHER SOURCE. This was the one read on either page that
|
|
869
|
+
// still consumed a body without consulting the status, and it is the read behind the open
|
|
870
|
+
// channel. A 500 here answers `{"error":"..."}`, which is valid JSON that `fetch` does not
|
|
871
|
+
// reject, so the catch below never fired: the object was handed to the order machine as a
|
|
872
|
+
// history, the channel drew empty, and nothing said why. Measured on the shipped code before
|
|
873
|
+
// this line changed: last-good gone, no backfill-failed note, no stale mark.
|
|
874
|
+
msgs = await window.COTAL_SNAPSHOT.readJson(
|
|
875
|
+
await fetch(`/api/channels/${encodeURIComponent(key)}/history?limit=200`),
|
|
876
|
+
`#${key} history`,
|
|
877
|
+
);
|
|
827
878
|
} catch (err) {
|
|
828
|
-
|
|
829
|
-
|
|
879
|
+
// A REFUSED READ KEEPS WHAT THE READER ALREADY HAD, for the same reason the four polled
|
|
880
|
+
// sources do. `refresh()` re-selects the open channel on EVERY poll, so on a link where the
|
|
881
|
+
// read keeps missing, an empty batch here emptied the open channel once per poll. The held
|
|
882
|
+
// messages take the place of the history that could not be read, which keeps the merge and
|
|
883
|
+
// the ordering below identical to the successful path.
|
|
884
|
+
refused = err && err.message ? err.message : String(err);
|
|
885
|
+
msgs = held;
|
|
886
|
+
noteOrder([{ type: "backfill-failed", channel: key, reason: refused }]);
|
|
830
887
|
} finally {
|
|
831
888
|
// SETTLED ON EVERY PATH, INCLUDING THE STALE ONE. A superseded load must not rebind the view it
|
|
832
889
|
// no longer owns, and it must still drain the machine it armed; skipping the settle is what
|
|
@@ -842,6 +899,12 @@ async function select(key) {
|
|
|
842
899
|
const ids = new Set(merged.map((m) => m && m.id));
|
|
843
900
|
for (const m of channelMsgs) if (m && !ids.has(m.id)) merged.push(m);
|
|
844
901
|
channelMsgs = merged.slice(-500);
|
|
902
|
+
shownChannel = key;
|
|
903
|
+
// SAID, NOT JUST KEPT. Retention without the mark shows old messages as though they were
|
|
904
|
+
// current, which is the half of this rule that turns a silent wipe into a silent lie. Marked
|
|
905
|
+
// per source: a refused history does not erase the poll's other marks, and a read that lands
|
|
906
|
+
// clears this one without touching theirs.
|
|
907
|
+
markStale(`#${key} history`, refused ? { kind: "refused", name: `#${key} history`, reason: refused } : null);
|
|
845
908
|
}
|
|
846
909
|
if (selecting && selecting.key === key) selecting = null;
|
|
847
910
|
release();
|
|
@@ -885,54 +948,118 @@ async function refresh() {
|
|
|
885
948
|
// The batch the settle will use. Only the all-activity path fills it; every other path settles on
|
|
886
949
|
// empty, which is the machine's specified empty-history arm rather than a shortcut.
|
|
887
950
|
let batch = [];
|
|
951
|
+
let activityPage = null;
|
|
888
952
|
try {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
953
|
+
// ── A FAILED READ KEEPS WHAT IS ON SCREEN ─────────────────────────────────────────────────────
|
|
954
|
+
//
|
|
955
|
+
// This was a sequential chain of `(await fetch(u)).json()`, and both of its properties were
|
|
956
|
+
// wrong on a slow link. A 500 body is valid JSON and `fetch` does not reject on one, so the
|
|
957
|
+
// REFUSAL was assigned into `roster` / `channels` / `dms` as if it were the snapshot; and the
|
|
958
|
+
// first read that did throw skipped every read after it, so one slow route emptied the feed and
|
|
959
|
+
// left the rest of the page un-refreshed with nothing saying so. Measured against a broker
|
|
960
|
+
// behind a 160ms link: `/api/activity` 500 `{"error":"timeout"}` produced
|
|
961
|
+
// `Uncaught TypeError: activity is not iterable` fifteen times in twenty-five seconds.
|
|
962
|
+
//
|
|
963
|
+
// `refreshAll` reads all four concurrently, applies ONLY the ones that succeeded, and returns
|
|
964
|
+
// what did not land. `apply` is the only writer, so a refusal cannot reach page state at all.
|
|
965
|
+
const SNAP = window.COTAL_SNAPSHOT;
|
|
966
|
+
// The all-activity backfill is a source ONLY when the reader is on all-activity. The other three
|
|
967
|
+
// lenses settle the order machine on an empty batch, which is its specified empty-history arm,
|
|
968
|
+
// and asking for a page nobody will draw is exactly the per-channel fan-out this change bounded.
|
|
969
|
+
const onAllActivity = !agentSel && !dmSel && selected === "*";
|
|
970
|
+
const stale = await SNAP.refreshAll([
|
|
971
|
+
// The space name used to be a one-shot at boot with a bare `fetch().then((r) => r.json())`, so
|
|
972
|
+
// a refusal arrived as data and the header read `· undefined` for the rest of the session:
|
|
973
|
+
// the same defect as the rest of this change, on the one read that never came back to correct
|
|
974
|
+
// itself. It is a source like any other now, so it is gated, named when it is stale, and
|
|
975
|
+
// replaced by the next poll that lands. `/api/meta` is `{space, pid}` computed in process, so
|
|
976
|
+
// reading it every poll costs no broker work.
|
|
977
|
+
{
|
|
978
|
+
name: "space",
|
|
979
|
+
read: async () => SNAP.readJson(await fetch("/api/meta"), "space"),
|
|
980
|
+
apply: (meta) => { $("space").textContent = `· ${meta.space}`; document.title = `Cotal · ${meta.space}`; },
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
name: "peers",
|
|
984
|
+
read: async () => SNAP.readJson(await fetch("/api/roster"), "peers"),
|
|
985
|
+
apply: (v) => { roster = v; refreshDerived(); },
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
name: "channels",
|
|
989
|
+
read: async () => SNAP.readJson(await fetch("/api/channels"), "channels"),
|
|
990
|
+
// L2 shape is flat {channel,messages,description?,replay,replayWindow?,deliveryClass}.
|
|
991
|
+
// Tolerate a nested-config server briefly (pre-restart) without re-deriving defaults.
|
|
992
|
+
apply: (list) => {
|
|
993
|
+
channels = new Map(
|
|
994
|
+
list.map((c) => {
|
|
995
|
+
if (c.replay !== undefined || c.deliveryClass !== undefined || (c.description && !c.config))
|
|
996
|
+
return [c.channel, c];
|
|
997
|
+
const cfg = c.config || {};
|
|
998
|
+
return [
|
|
999
|
+
c.channel,
|
|
1000
|
+
{
|
|
1001
|
+
messages: c.messages,
|
|
1002
|
+
description: cfg.description,
|
|
1003
|
+
replay: cfg.replay,
|
|
1004
|
+
replayWindow: cfg.replayWindow,
|
|
1005
|
+
deliveryClass: cfg.deliveryClass,
|
|
1006
|
+
},
|
|
1007
|
+
];
|
|
1008
|
+
}),
|
|
1009
|
+
);
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
name: "direct messages",
|
|
1014
|
+
read: async () => SNAP.readJson(await fetch("/api/dms?limit=500"), "direct messages"),
|
|
1015
|
+
apply: (v) => { dms = v; },
|
|
1016
|
+
},
|
|
1017
|
+
// Read alongside the other three rather than after them, so a refusal on it is reported in the
|
|
1018
|
+
// same place instead of being swallowed, and so it no longer waits for them on a slow link.
|
|
1019
|
+
//
|
|
1020
|
+
// `/api/activity` answers an ENVELOPE now, never a bare array, and the shape change is a guard
|
|
1021
|
+
// rather than a nuisance: a caller that ignores `partial` breaks here instead of rendering a
|
|
1022
|
+
// short page as a complete one. NO TOLERANCE FOR A BARE ARRAY either, and that is not
|
|
1023
|
+
// pedantry: `[].entries` is a real Array METHOD, so a `page.entries ?? page` tolerance quietly
|
|
1024
|
+
// hands a FUNCTION to the merge when the answer is a list. It is read exactly one way.
|
|
1025
|
+
...(onAllActivity
|
|
1026
|
+
? [{
|
|
1027
|
+
name: "activity",
|
|
1028
|
+
read: async () => SNAP.readJson(await fetch(`/api/activity?limit=200`), "activity"),
|
|
1029
|
+
apply: (page) => { batch = page.entries; activityPage = page; },
|
|
1030
|
+
}]
|
|
1031
|
+
: []),
|
|
1032
|
+
]);
|
|
1033
|
+
// A PARTIAL PAGE IS NOT A REFUSAL AND IS NOT SILENCE. The entries it carries are real and are
|
|
1034
|
+
// applied; what the reader is told is that some sources did not answer in time, which sources,
|
|
1035
|
+
// and how many did. Reported on the same marker as a refusal so there is one place to look.
|
|
1036
|
+
if (activityPage && activityPage.partial)
|
|
1037
|
+
stale.push({
|
|
1038
|
+
kind: "partial",
|
|
1039
|
+
name: "activity",
|
|
1040
|
+
reason: `${activityPage.read} of ${activityPage.of} sources answered within ${activityPage.deadlineMs}ms; missing ${activityPage.missing.join(", ")}`,
|
|
1041
|
+
});
|
|
1042
|
+
setStale(stale);
|
|
1043
|
+
// The order machine's own failure arm keeps its own note: a refused history read is what makes a
|
|
1044
|
+
// backfill incomplete, and the notice that draws it is about ordering. The other three sources
|
|
1045
|
+
// are carried by the stale pill and are not backfill failures, so they are not reported as ones.
|
|
1046
|
+
const backfillRefused = stale.find((s) => s.name === "activity" && s.kind !== "partial");
|
|
1047
|
+
if (backfillRefused) noteOrder([{ type: "backfill-failed", reason: backfillRefused.reason }]);
|
|
912
1048
|
renderSidebarNav();
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1049
|
+
// THE BOUNDARY MUST PASS EVEN WHEN THE FETCH DOES NOT, and this is not a soft failure mode
|
|
1050
|
+
// invented here. `pending` is drained only by the settle, so a rejected request used to mean the
|
|
1051
|
+
// machine never settled and every frame held during it stayed invisible for the life of the page,
|
|
1052
|
+
// with nothing on screen saying so. That is strictly worse than what this code replaced, where a
|
|
1053
|
+
// failed fetch simply left the live arrivals in place.
|
|
1054
|
+
//
|
|
1055
|
+
// A failed history read IS an empty history batch: the machine already specifies that case, and
|
|
1056
|
+
// specifies that the baseline then comes from the earliest BUFFERED frame. So the boundary is
|
|
1057
|
+
// settled on empty rather than skipped, and the refusal is SURFACED (as a stale mark above and a
|
|
1058
|
+
// note here) instead of being swallowed. Reporting it is what keeps this from being a silent degrade.
|
|
1059
|
+
if (agentSel || dmSel) {
|
|
916
1060
|
renderCenter();
|
|
917
1061
|
} else if (selected !== "*") {
|
|
918
1062
|
select(selected);
|
|
919
|
-
} else {
|
|
920
|
-
// THE BOUNDARY MUST PASS EVEN WHEN THE FETCH DOES NOT, and this is not a soft failure mode
|
|
921
|
-
// invented here. `pending` is drained only by the settle, so a rejected request used to mean the
|
|
922
|
-
// machine never settled and every frame held during it stayed invisible for the life of the page,
|
|
923
|
-
// with nothing on screen saying so. That is strictly worse than what this code replaced, where a
|
|
924
|
-
// failed fetch simply left the live arrivals in place.
|
|
925
|
-
//
|
|
926
|
-
// A failed history read IS an empty history batch: the machine already specifies that case, and
|
|
927
|
-
// specifies that the baseline then comes from the earliest BUFFERED frame. So the boundary is
|
|
928
|
-
// settled on empty rather than skipped, and the failure is SURFACED as a note instead of being
|
|
929
|
-
// swallowed. Reporting it is what keeps this from being a silent degrade.
|
|
930
|
-
try {
|
|
931
|
-
batch = await (await fetch("/api/activity?limit=200")).json();
|
|
932
|
-
} catch (err) {
|
|
933
|
-
batch = [];
|
|
934
|
-
noteOrder([{ type: "backfill-failed", reason: err && err.message ? err.message : String(err) }]);
|
|
935
|
-
}
|
|
936
1063
|
}
|
|
937
1064
|
} finally {
|
|
938
1065
|
// ── THE SETTLE, ON EVERY EXIT PATH ────────────────────────────────────────────────────────────
|
|
@@ -942,8 +1069,9 @@ async function refresh() {
|
|
|
942
1069
|
// machine that this function had armed and would never settle, and none of them reached the feed.
|
|
943
1070
|
// Switching back to all activity showed a feed that had never received them, and the next refresh
|
|
944
1071
|
// replaced the machine and took the buffer with it. A `finally` is the only placement that
|
|
945
|
-
// survives all four branches plus a throw from
|
|
946
|
-
//
|
|
1072
|
+
// survives all four branches plus a throw from anything between the arm and here. The source
|
|
1073
|
+
// reads no longer supply that throw, because a refusal is reported rather than propagated; the
|
|
1074
|
+
// renders below the reads still can, and the cost of missing it is the same.
|
|
947
1075
|
activity = batch;
|
|
948
1076
|
// Same trust rule as the live feed: the backfill is tagged with the channel the SERVER
|
|
949
1077
|
// requested, so the payload claim is overwritten at ingress rather than downstream.
|
|
@@ -1274,12 +1402,6 @@ if (isDemo) {
|
|
|
1274
1402
|
document.title = "Cotal · demo";
|
|
1275
1403
|
renderDemo();
|
|
1276
1404
|
} else {
|
|
1277
|
-
fetch("/api/meta")
|
|
1278
|
-
.then((r) => r.json())
|
|
1279
|
-
.then((m) => {
|
|
1280
|
-
$("space").textContent = `· ${m.space}`;
|
|
1281
|
-
document.title = `Cotal · ${m.space}`;
|
|
1282
|
-
});
|
|
1283
1405
|
refresh();
|
|
1284
1406
|
connect();
|
|
1285
1407
|
}
|
package/dist/web/graph.html
CHANGED
|
@@ -178,6 +178,7 @@
|
|
|
178
178
|
</span>
|
|
179
179
|
<a class="navlink" href="/">← Monitor</a>
|
|
180
180
|
<span class="pill down" id="conn"><span class="d"></span><span class="t">connecting</span></span>
|
|
181
|
+
<span class="pill stale" id="stale" hidden><span class="d"></span><span class="t"></span></span>
|
|
181
182
|
<span class="pill off" id="feed" hidden><span class="d"></span><span class="t">membership</span></span>
|
|
182
183
|
<div class="ctrls">
|
|
183
184
|
<div class="grp" id="modes">
|
|
@@ -213,6 +214,7 @@
|
|
|
213
214
|
<aside id="detail" class="glass"></aside>
|
|
214
215
|
<div class="hint" id="hint">click a node for detail · scroll to zoom · drag to pan</div>
|
|
215
216
|
|
|
217
|
+
<script src="/snapshot.js"></script>
|
|
216
218
|
<script src="/harness.js"></script>
|
|
217
219
|
<script src="/parts.js"></script>
|
|
218
220
|
<script src="/agui-frame.js"></script>
|
package/dist/web/graph.js
CHANGED
|
@@ -269,6 +269,30 @@
|
|
|
269
269
|
// into it: they are different facts and the pill has to say which one.
|
|
270
270
|
function membershipUnreadable() { feed.unreadable = true; setFeed(); }
|
|
271
271
|
|
|
272
|
+
// ── THE BOOTSTRAP MUST NOT OUTRANK THE LIVE FEED ────────────────────────────────────────────
|
|
273
|
+
//
|
|
274
|
+
// Every bootstrap read is ISSUED before its value is applied: `refreshAll` starts all six, awaits
|
|
275
|
+
// all six, and only then applies each. So a snapshot is always at least as old as the moment the
|
|
276
|
+
// page asked for it, while an SSE event is by definition newer than that moment. Now that the feed
|
|
277
|
+
// opens FIRST, a live `roster` or `membership` can land while those reads are still in flight, and
|
|
278
|
+
// applying the older snapshot afterwards silently reverts it: `updateRoster` on an empty list marks
|
|
279
|
+
// every unseen agent `present = false` and deletes it outright unless it is still a feed member,
|
|
280
|
+
// and `applyMembership` on an empty set clears `memberOf`. The agent the feed just announced
|
|
281
|
+
// vanishes from the graph.
|
|
282
|
+
//
|
|
283
|
+
// Both channels carry a FULL snapshot through the SAME apply function, so a live event does not
|
|
284
|
+
// need merging with the older read, it REPLACES it. Once the feed has spoken for a source, that
|
|
285
|
+
// source's bootstrap value is stale on arrival and is dropped rather than applied.
|
|
286
|
+
//
|
|
287
|
+
// WHAT IS SUPERSEDED IS THE SOURCE, NOT THE SNAPSHOT. `membership` speaks in two sentences, a
|
|
288
|
+
// snapshot and a REFUSAL, and each side can say either one. Writing the rule only onto the apply
|
|
289
|
+
// wrappers covered the snapshots and left both refusals loose, in opposite directions: a live
|
|
290
|
+
// refusal erased by an older successful read, and a successful live snapshot overruled by a
|
|
291
|
+
// bootstrap read that refused after it. Both end in the header pill making a claim about the mesh
|
|
292
|
+
// that is really a claim about one read, which is the one thing this pill exists not to do.
|
|
293
|
+
const liveApplied = new Set();
|
|
294
|
+
const supersededByFeed = (name, apply) => (value) => { if (!liveApplied.has(name)) apply(value); };
|
|
295
|
+
|
|
272
296
|
function applyMembership(snap) {
|
|
273
297
|
if (!snap) return;
|
|
274
298
|
feed.unreadable = false; // a snapshot arrived, whatever it contains
|
|
@@ -683,31 +707,76 @@
|
|
|
683
707
|
$("legendToggle").onclick = () => $("legend").classList.toggle("collapsed");
|
|
684
708
|
function setConn(live) { const el = $("conn"); el.classList.toggle("down", !live); el.querySelector(".t").textContent = live ? "live" : "disconnected"; }
|
|
685
709
|
|
|
710
|
+
/** Say which sources are showing their last good value. Visible, not a console line. */
|
|
711
|
+
function setStale(stale) {
|
|
712
|
+
const el = $("stale");
|
|
713
|
+
if (!el) return;
|
|
714
|
+
const label = window.COTAL_SNAPSHOT.staleLabel(stale);
|
|
715
|
+
el.hidden = !label;
|
|
716
|
+
if (!label) return;
|
|
717
|
+
el.querySelector(".t").textContent = label;
|
|
718
|
+
el.title = stale.map((s) => `${s.name}: ${s.reason}`).join("\n");
|
|
719
|
+
}
|
|
720
|
+
|
|
686
721
|
// ── boot ──
|
|
687
722
|
async function load() {
|
|
688
|
-
|
|
689
|
-
|
|
723
|
+
// ── ONE REFUSED READ MUST NOT EMPTY THE PAGE ────────────────────────────────────────────────
|
|
724
|
+
//
|
|
725
|
+
// This was a six-way `Promise.all` of `fetch(u).then((r) => r.json())`. `fetch` does not reject
|
|
726
|
+
// on a 500 and this server's 500 body is `{"error": "..."}`, which parses, so the refusal
|
|
727
|
+
// arrived as DATA and the first `for (const c of chans)` threw `chans is not iterable` out of
|
|
728
|
+
// the whole bootstrap. The `.catch(() => [])` guards on activity and dms never fired for the
|
|
729
|
+
// same reason: there was nothing to catch. Because `load()` rejected, `connect()`, which runs
|
|
730
|
+
// as `load().then(connect)`, was never called, so the page sat at `disconnected` with no peers
|
|
731
|
+
// and no channel hubs and could not recover without a reload. Measured against a broker behind
|
|
732
|
+
// a 160ms link, where `/api/channels` and `/api/activity` both returned 500 `timeout`.
|
|
733
|
+
//
|
|
734
|
+
// Now every source is read independently, only successful reads are applied, and what did not
|
|
735
|
+
// land is named on screen. Nothing here can prevent `connect()` from running.
|
|
736
|
+
const SNAP = window.COTAL_SNAPSHOT;
|
|
737
|
+
let activityPage = null;
|
|
738
|
+
const stale = await SNAP.refreshAll([
|
|
739
|
+
{ name: "space", read: async () => SNAP.readJson(await fetch("/api/meta"), "space"),
|
|
740
|
+
apply: (meta) => { $("space").textContent = "· " + meta.space; } },
|
|
741
|
+
{ name: "channels", read: async () => SNAP.readJson(await fetch("/api/channels"), "channels"),
|
|
742
|
+
apply: (chans) => { for (const c of chans) { const h = ensureHub(c.channel); h.msgs = c.messages || 0; h.desc = c.description || ""; h.deliveryClass = c.deliveryClass; h.replay = c.replay; h.replayWindow = c.replayWindow; } } },
|
|
743
|
+
{ name: "peers", read: async () => SNAP.readJson(await fetch("/api/roster"), "peers"),
|
|
744
|
+
apply: supersededByFeed("peers", (roster) => updateRoster(roster)) },
|
|
690
745
|
// `.catch(() => ({members: []}))` here turned a failed fetch into an empty snapshot, which the
|
|
691
746
|
// pill then reported as "traffic-only" — the client half of the same defect the server had.
|
|
692
747
|
// A non-200 is not a snapshot either: `r.json()` on the refusal body would parse fine and
|
|
693
|
-
// arrive as data, so the status is checked before the body is trusted.
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
748
|
+
// arrive as data, so the status is checked before the body is trusted. That check now lives in
|
|
749
|
+
// `readJson`, and an unreadable feed reaches `membershipUnreadable()` through the stale path.
|
|
750
|
+
{ name: "membership", read: async () => SNAP.readJson(await fetch("/api/membership"), "membership"),
|
|
751
|
+
apply: supersededByFeed("membership", (m) => applyMembership(m)) },
|
|
752
|
+
// `/api/activity` answers an ENVELOPE, never a bare array; a caller that ignored `partial`
|
|
753
|
+
// would break here rather than seed a short page as though it were the whole backfill.
|
|
754
|
+
{ name: "activity", read: async () => SNAP.readJson(await fetch("/api/activity?limit=400"), "activity"),
|
|
755
|
+
apply: (page) => { activityPage = page; seedActivity(page.entries); } },
|
|
756
|
+
{ name: "direct messages", read: async () => SNAP.readJson(await fetch("/api/dms?limit=400"), "direct messages"),
|
|
757
|
+
apply: (dmHist) => seedDms(dmHist) },
|
|
698
758
|
]);
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
759
|
+
if (activityPage && activityPage.partial)
|
|
760
|
+
stale.push({
|
|
761
|
+
kind: "partial",
|
|
762
|
+
name: "activity",
|
|
763
|
+
reason: `${activityPage.read} of ${activityPage.of} sources answered within ${activityPage.deadlineMs}ms; missing ${activityPage.missing.join(", ")}`,
|
|
764
|
+
});
|
|
765
|
+
setStale(stale);
|
|
766
|
+
// A bootstrap read that REFUSED is a sentence about the membership source like any other, so it
|
|
767
|
+
// obeys the same rule as the snapshot beside it: it is a fact about that one read, and the live
|
|
768
|
+
// feed may already have said something newer. Routed through `supersededByFeed` rather than a
|
|
769
|
+
// second copy of the condition, because two spellings of one rule is how two halves drift apart.
|
|
770
|
+
if (stale.some((s) => s.name === "membership")) supersededByFeed("membership", membershipUnreadable)();
|
|
771
|
+
alpha = 1; for (let i = 0; i < 200; i++) physics(); // pre-warm to a settled layout
|
|
772
|
+
const f = fitTarget(); cam.x = f.x; cam.y = f.y; cam.scale = f.scale;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Seed traffic glow + the `recent` buffer from the activity backfill so the channel detail's
|
|
776
|
+
* "recently active" tags and the "recent" section aren't empty until the first live SSE message
|
|
777
|
+
* arrives. Its own function so the read that feeds it can fail without taking the boot with it. */
|
|
778
|
+
function seedActivity(activity) {
|
|
707
779
|
for (const e of activity) { const m = e.msg; if (m) m.channel = e.channel; const a = m?.from?.id && agents.get(m.from.id); if (e.mode === "chat" && m?.channel && a) chatHit(a, m.channel, m.ts || now()); }
|
|
708
|
-
for (const m of dmHist) { const a = m.from?.id && agents.get(m.from.id), b = typeof m.to === "string" && agents.get(m.to); if (a && b && a !== b) dmHit(a, b, m.ts || now()); }
|
|
709
|
-
// Seed the `recent` buffer from the activity backfill so the channel detail's "recently active" tags +
|
|
710
|
-
// the "recent" section aren't empty until the first live SSE message arrives (norman).
|
|
711
780
|
for (const e of activity.slice(-80)) {
|
|
712
781
|
const m = e.msg; if (!m) continue;
|
|
713
782
|
const to = e.mode === "unicast" ? (typeof m.to === "string" ? (agents.get(m.to)?.name || shortId(m.to)) : m.to?.name) : e.mode === "anycast" ? "@" + (m.toService || "") : null;
|
|
@@ -715,13 +784,26 @@
|
|
|
715
784
|
}
|
|
716
785
|
recent.sort((a, b) => a.ts - b.ts);
|
|
717
786
|
if (recent.length > 80) recent.splice(0, recent.length - 80);
|
|
718
|
-
alpha = 1; for (let i = 0; i < 200; i++) physics(); // pre-warm to a settled layout
|
|
719
|
-
const f = fitTarget(); cam.x = f.x; cam.y = f.y; cam.scale = f.scale;
|
|
720
787
|
}
|
|
721
|
-
|
|
788
|
+
|
|
789
|
+
function seedDms(dmHist) {
|
|
790
|
+
for (const m of dmHist) { const a = m.from?.id && agents.get(m.from.id), b = typeof m.to === "string" && agents.get(m.to); if (a && b && a !== b) dmHit(a, b, m.ts || now()); }
|
|
791
|
+
}
|
|
792
|
+
function connect() { const es = new EventSource("/feed"); es.onopen = () => setConn(true); es.onerror = () => setConn(false); es.addEventListener("roster", (e) => { liveApplied.add("peers"); updateRoster(JSON.parse(e.data)); }); es.addEventListener("membership", (e) => { liveApplied.add("membership"); applyMembership(JSON.parse(e.data)); }); es.addEventListener("membership-read-failed", () => { liveApplied.add("membership"); membershipUnreadable(); }); es.addEventListener("message", (e) => onMessage(JSON.parse(e.data))); }
|
|
722
793
|
|
|
723
794
|
resize();
|
|
724
795
|
setInterval(setFeed, 5000); // age "live" → "stale" even without new events
|
|
725
|
-
|
|
796
|
+
// THE FEED IS NOT GATED ON THE BOOTSTRAP, IN EITHER SENSE. It was once chained behind `load()`
|
|
797
|
+
// RESOLVING, so a single refused read left the page permanently disconnected with nothing on it.
|
|
798
|
+
// That was fixed by making `load()` never reject, which guaranteed `connect()` would RUN but not
|
|
799
|
+
// that it would run SOON: chained with `.then`, it still waited for the whole bootstrap, and that
|
|
800
|
+
// bootstrap reads `/api/activity?limit=400` and `/api/dms?limit=400`, both bounded by the
|
|
801
|
+
// aggregation deadline. On a slow link the page therefore read `disconnected` for the entire load
|
|
802
|
+
// window. Observed across a WAN link as "always showing disconnected, and taking long to show the
|
|
803
|
+
// graph". The live feed is exactly what a page showing stale data needs most, so it opens FIRST
|
|
804
|
+
// and the bootstrap fills in around it. The Monitor page has always done this: `app.js` ends with
|
|
805
|
+
// `refresh(); connect();`, concurrent, not chained.
|
|
806
|
+
connect();
|
|
807
|
+
load().catch((err) => console.error(err));
|
|
726
808
|
requestAnimationFrame(frame);
|
|
727
809
|
})();
|
package/dist/web/index.html
CHANGED
|
@@ -52,6 +52,9 @@
|
|
|
52
52
|
.pill .t { font-size: 11px; font-weight: 600; color: var(--green); }
|
|
53
53
|
.pill.down { background: #2a1717; }
|
|
54
54
|
.pill.down .d { background: var(--red); } .pill.down .t { color: var(--red); }
|
|
55
|
+
/* last-good data on screen, refresh refused - amber, and it carries a text label, never colour alone */
|
|
56
|
+
.pill.stale { background: var(--tint-amber); }
|
|
57
|
+
.pill.stale .d { background: var(--amber); } .pill.stale .t { color: var(--amber); }
|
|
55
58
|
|
|
56
59
|
/* HealthBand tiles */
|
|
57
60
|
.tiles { margin-left: auto; display: flex; gap: 10px; }
|
|
@@ -474,6 +477,7 @@
|
|
|
474
477
|
<span class="title">Cotal</span>
|
|
475
478
|
<span class="space" id="space"></span>
|
|
476
479
|
<span class="pill down" id="conn"><span class="d"></span><span class="t">connecting</span></span>
|
|
480
|
+
<span class="pill stale" id="stale" hidden><span class="d"></span><span class="t"></span></span>
|
|
477
481
|
<a class="brand-graph" href="/graph">Graph view →</a>
|
|
478
482
|
</span>
|
|
479
483
|
<div class="tiles" id="tiles"></div>
|
|
@@ -502,6 +506,7 @@
|
|
|
502
506
|
</main>
|
|
503
507
|
<script src="/vendor/marked.umd.js"></script>
|
|
504
508
|
<script src="/vendor/purify.min.js"></script>
|
|
509
|
+
<script src="/snapshot.js"></script>
|
|
505
510
|
<script src="/harness.js"></script>
|
|
506
511
|
<script src="/parts.js"></script>
|
|
507
512
|
<script src="/agui-frame.js"></script>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// How this dashboard survives a poll that fails, on both pages.
|
|
2
|
+
//
|
|
3
|
+
// A REFUSAL IS NOT DATA, AND THIS SURFACE COULD NOT TELL THEM APART. The server answers a failed
|
|
4
|
+
// route with a 500 whose body is `{"error": "..."}` - valid JSON. `fetch()` does not reject on a
|
|
5
|
+
// 500 either, so `fetch(u).then((r) => r.json())` resolves with the REFUSAL, and the page then
|
|
6
|
+
// treats it as the snapshot. Measured on the shipped pages against a broker behind a 160ms link:
|
|
7
|
+
// `/api/activity` returned 500 `{"error":"timeout"}`, `/graph` threw `TypeError: chans is not
|
|
8
|
+
// iterable` out of its bootstrap and never reached `connect()`, so the pill said `disconnected`
|
|
9
|
+
// with no peers and no channels and the page never recovered; `/` threw `activity is not iterable`
|
|
10
|
+
// fifteen times in twenty-five seconds. Both are the same bug: the status was never consulted, so
|
|
11
|
+
// the one thing that separated a refusal from an empty result never reached the code.
|
|
12
|
+
//
|
|
13
|
+
// THE ORDER OF THE TWO RULES IS THE FIX. First, a non-200 is turned into a THROW that names its
|
|
14
|
+
// condition. Second, a throwing read leaves the value the page already holds exactly where it is
|
|
15
|
+
// and is reported STALE. Either rule alone is not enough: checking the status without keeping the
|
|
16
|
+
// last good snapshot converts a silent corruption into a visible wipe, and keeping the last good
|
|
17
|
+
// snapshot without checking the status keeps nothing, because the refusal arrived as a successful
|
|
18
|
+
// parse and was never a failure to begin with.
|
|
19
|
+
//
|
|
20
|
+
// WHAT STALE MEANS HERE, AND WHY IT IS NOT A FALLBACK. The page keeps showing data it already had
|
|
21
|
+
// and SAYS SO. It does not invent a value, does not substitute a default, and does not silently
|
|
22
|
+
// degrade: the reader is told, per source, that what they are looking at is the last thing that
|
|
23
|
+
// was actually read and why the refresh did not land. Recovery is the next successful read, which
|
|
24
|
+
// replaces the value and clears the mark.
|
|
25
|
+
(() => {
|
|
26
|
+
/** Parse a JSON response, refusing a non-200 rather than handing its body back as data. The
|
|
27
|
+
* refusal names the source and the status, and carries the server's own `error` text when the
|
|
28
|
+
* body has one, so a caller that only logs it still says something true. */
|
|
29
|
+
async function readJson(res, what) {
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
let detail = "";
|
|
32
|
+
try {
|
|
33
|
+
const body = await res.json();
|
|
34
|
+
if (body && typeof body.error === "string") detail = `: ${body.error}`;
|
|
35
|
+
} catch {
|
|
36
|
+
/* a refusal need not be JSON; the status is the fact that matters */
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`${what} refused with HTTP ${res.status}${detail}`);
|
|
39
|
+
}
|
|
40
|
+
return res.json();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read every source concurrently and apply ONLY the ones that succeeded.
|
|
44
|
+
*
|
|
45
|
+
* `sources` is `[{ name, read, apply }]`. `apply` is called with the value of a successful read
|
|
46
|
+
* and is the ONLY place a source's state is written, so a failed read cannot reach it - the
|
|
47
|
+
* retention is structural rather than a rule someone has to remember at each call site.
|
|
48
|
+
* Returns the stale list, `[{ name, reason }]`, empty when everything landed.
|
|
49
|
+
*
|
|
50
|
+
* Concurrent, not sequential: on a slow link the pre-existing sequential chain made every source
|
|
51
|
+
* wait for the slowest one, and a throw part-way through skipped the rest entirely.
|
|
52
|
+
*
|
|
53
|
+
* WHAT IS CAUGHT IS THE READ, AND ONLY THE READ. A refusal or a network failure is this function's
|
|
54
|
+
* business and is turned into a named entry. An `apply` that throws is a defect in the page, not a
|
|
55
|
+
* fact about the link, and it is allowed to propagate: swallowing it would hide a broken renderer
|
|
56
|
+
* behind a stale marker that says the network is at fault. The cost is stated rather than hidden:
|
|
57
|
+
* a throwing apply ends the loop, so the sources after it are not applied and the caller does not
|
|
58
|
+
* reach its marker. Every apply here is a plain assignment or a render the page owns. */
|
|
59
|
+
async function refreshAll(sources) {
|
|
60
|
+
const settled = await Promise.all(
|
|
61
|
+
sources.map(async (s) => {
|
|
62
|
+
try {
|
|
63
|
+
return { value: await s.read() };
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return { error: e };
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
const stale = [];
|
|
70
|
+
for (let i = 0; i < settled.length; i++) {
|
|
71
|
+
const r = settled[i];
|
|
72
|
+
if ("error" in r) {
|
|
73
|
+
const e = r.error;
|
|
74
|
+
stale.push({ kind: "refused", name: sources[i].name, reason: e && e.message ? e.message : String(e) });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
sources[i].apply(r.value);
|
|
78
|
+
}
|
|
79
|
+
return stale;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One line for the header: what is stale, what is partial, and nothing when neither.
|
|
83
|
+
*
|
|
84
|
+
* The two are DIFFERENT FACTS and the label keeps them apart. Stale means the page is showing the
|
|
85
|
+
* last value that was read because the refresh was refused. Partial means the refresh DID land and
|
|
86
|
+
* is short: some sources did not answer inside the request's deadline. Folding them into one word
|
|
87
|
+
* would tell a reader that data is old when it is actually new and incomplete. */
|
|
88
|
+
function staleLabel(stale) {
|
|
89
|
+
if (!stale.length) return "";
|
|
90
|
+
const refused = stale.filter((s) => s.kind !== "partial").map((s) => s.name);
|
|
91
|
+
const partial = stale.filter((s) => s.kind === "partial").map((s) => s.name);
|
|
92
|
+
const parts = [];
|
|
93
|
+
if (refused.length) parts.push(`stale: ${refused.join(", ")}`);
|
|
94
|
+
if (partial.length) parts.push(`partial: ${partial.join(", ")}`);
|
|
95
|
+
return parts.join(" · ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
window.COTAL_SNAPSHOT = { readJson, refreshAll, staleLabel };
|
|
99
|
+
})();
|