@slack/radar-mcp 1.6.0 → 1.8.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 +4 -3
- package/dist/mcp/api-paths.d.ts +1 -0
- package/dist/mcp/api-paths.js +108 -0
- package/dist/mcp/index.js +52 -83
- package/dist/mcp/logs-result.d.ts +9 -0
- package/dist/mcp/logs-result.js +15 -0
- package/dist/mcp/radar-response.d.ts +21 -0
- package/dist/mcp/radar-response.js +38 -0
- package/dist/mcp/snapshot.d.ts +4 -2
- package/dist/mcp/snapshot.js +25 -12
- package/dist/mcp/tools.js +34 -10
- package/dist/shared/android.d.ts +2 -2
- package/dist/shared/android.js +16 -11
- package/dist/shared/db.d.ts +13 -0
- package/dist/shared/db.js +30 -3
- package/dist/shared/screen.d.ts +10 -0
- package/dist/shared/screen.js +13 -1
- package/dist/shared/stream.d.ts +1 -1
- package/dist/shared/transport.d.ts +12 -4
- package/dist/web/public/index.html +242 -1126
- package/dist/web/public/next/app.js +210 -0
- package/dist/web/public/next/db.js +286 -0
- package/dist/web/public/next/esc.js +13 -0
- package/dist/web/public/next/feed.js +608 -0
- package/dist/web/public/next/filters.js +120 -0
- package/dist/web/public/next/hooks.js +57 -0
- package/dist/web/public/next/html.js +10 -0
- package/dist/web/public/next/inspector.js +258 -0
- package/dist/web/public/next/registry.js +65 -0
- package/dist/web/public/next/screen.js +180 -0
- package/dist/web/public/next/spec-types.js +1 -0
- package/dist/web/public/next/store.js +766 -0
- package/dist/web/public/next/widgets.js +194 -0
- package/dist/web/server.js +7 -1
- package/package.json +3 -2
|
@@ -1,1141 +1,257 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en"><head><meta charset="utf-8"/>
|
|
3
3
|
<title>Slack Radar Dashboard</title>
|
|
4
|
+
<!-- Apply the theme BEFORE first paint so there is no flash on load. A saved choice
|
|
5
|
+
(radar-theme) always wins; with NO saved choice the default follows the OS via
|
|
6
|
+
prefers-color-scheme. MUST stay an inline synchronous <head> script: a module/deferred
|
|
7
|
+
load would paint the dark default first and flash. try/catch covers private-mode storage. -->
|
|
8
|
+
<script>try{var t=localStorage.getItem('radar-theme');var light=t?t==='light':(window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches);if(light)document.documentElement.classList.add('light');}catch(e){}</script>
|
|
4
9
|
<style>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
/* ---- THEME TOKENS. Dark is the default (:root); :root.light overrides. Every color in
|
|
11
|
+
the UI references a token, so the theme switch is one class on <html>. The full token
|
|
12
|
+
set ships in Foundation so the stacked container PRs are pure logic diffs and never
|
|
13
|
+
serially conflict on this one stylesheet; component rules are inert until their
|
|
14
|
+
component lands. ---- */
|
|
15
|
+
:root {
|
|
16
|
+
color-scheme: dark; --row-h:24px;
|
|
17
|
+
--bg:#0b0f14; --surface:#121925; --surface-2:#0e151e; --surface-3:#141d2b; --inset:#0c1117;
|
|
18
|
+
--text:#e8f0f8; --text-2:#cfd8e3; --muted:#5f7186; --muted-2:#8aa0b4;
|
|
19
|
+
--border:#1e2a3a; --border-2:#243244; --border-3:#2c3e52; --rowline:#11161d;
|
|
20
|
+
--accent:#39d98a; --accent-bg:#1c3a2a; --accent-text:#39d98a;
|
|
21
|
+
--blue:#6cb6ff; --blue-2:#7d9bd6; --purple:#c39bff;
|
|
22
|
+
--warn:#ffb454; --warn-2:#ffd479; --error:#ff6b60; --error-2:#ff5b50;
|
|
23
|
+
--hover:#11202e; --sel:#1d3a52; --hover-soft:#172230; --sel-soft:#1d2c3e;
|
|
24
|
+
--shadow:rgba(0,0,0,.5); --overlay:rgba(0,0,0,.45);
|
|
25
|
+
--chip-net-bg:#13314a; --chip-rtm-bg:#2c1f47; --chip-clog-bg:#10332a; --chip-log-bg:#2a2a2a; --chip-log-fg:#b0b0b0;
|
|
26
|
+
--lvl-err-bg:#3a1513; --lvl-warn-bg:#3a2f10; --lvl-info-bg:#10283a; --lvl-dbg-bg:#222;
|
|
27
|
+
--copybtn-bg:#172a1f; --copybtn-border:#2c5a3e; --copybtn-fg:#7fd6a3;
|
|
28
|
+
--j-key:#7fb0ff; --j-str:#9fe8bd; --j-num:#ffd479; --j-bool:#c39bff; --j-null:#7e8a99;
|
|
29
|
+
}
|
|
30
|
+
:root.light {
|
|
31
|
+
color-scheme: light;
|
|
32
|
+
--bg:#f4f6f9; --surface:#ffffff; --surface-2:#f0f3f7; --surface-3:#eef2f7; --inset:#ffffff;
|
|
33
|
+
--text:#1a2330; --text-2:#33404f; --muted:#6b7787; --muted-2:#4a5666;
|
|
34
|
+
--border:#d4dde7; --border-2:#c3cedb; --border-3:#aebccb; --rowline:#e7edf3;
|
|
35
|
+
--accent:#0e9f6e; --accent-bg:#d6f3e6; --accent-text:#0a7d56;
|
|
36
|
+
--blue:#1d6fd0; --blue-2:#2f6bbf; --purple:#7a3fd0;
|
|
37
|
+
--warn:#b06c00; --warn-2:#8a5a00; --error:#d83a30; --error-2:#e0473d;
|
|
38
|
+
--hover:#eaf1f8; --sel:#d4e6f7; --hover-soft:#eaf1f8; --sel-soft:#dbe9f7;
|
|
39
|
+
--shadow:rgba(20,40,70,.18); --overlay:rgba(20,30,45,.28);
|
|
40
|
+
--chip-net-bg:#dbeafe; --chip-rtm-bg:#ecddfb; --chip-clog-bg:#d6f3e6; --chip-log-bg:#e7ebf0; --chip-log-fg:#4a5666;
|
|
41
|
+
--lvl-err-bg:#fbdcd9; --lvl-warn-bg:#faedcf; --lvl-info-bg:#dbeafe; --lvl-dbg-bg:#e7ebf0;
|
|
42
|
+
--copybtn-bg:#dcf3e7; --copybtn-border:#9cd6b8; --copybtn-fg:#0a7d56;
|
|
43
|
+
--j-key:#1d6fd0; --j-str:#0a7d56; --j-num:#9a6700; --j-bool:#7a3fd0; --j-null:#8a96a3;
|
|
44
|
+
}
|
|
45
|
+
* { box-sizing: border-box; }
|
|
46
|
+
html, body { height:100%; }
|
|
47
|
+
/* App-style fixed-height layout: the PAGE never scrolls; only the feed scrolls internally.
|
|
48
|
+
The min-height:0 chain (#app -> stage -> feedwrap -> vp) is LOAD-BEARING for the feed
|
|
49
|
+
virtualization, which measures the flex-filled viewport height via ResizeObserver. */
|
|
50
|
+
body { margin:0; font:14px/1.35 ui-monospace,Menlo,Consolas,monospace; background:var(--bg); color:var(--text); padding:12px 16px; overflow:hidden; }
|
|
51
|
+
body.screenopen { padding-right:356px; }
|
|
52
|
+
#app { height:100%; display:flex; flex-direction:column; min-height:0; }
|
|
53
|
+
h1 { font-size:13px; font-weight:600; color:var(--muted-2); margin:0 0 6px; letter-spacing:.04em; display:flex; align-items:center; gap:8px; flex-wrap:wrap; flex:0 0 auto; }
|
|
9
54
|
.conn { font-size:13px; font-weight:600; }
|
|
10
|
-
.conn.connected { color
|
|
11
|
-
.
|
|
12
|
-
.reconnect {
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
.
|
|
16
|
-
.modebar { display:flex; gap:6px; margin:
|
|
17
|
-
.modebtn { background
|
|
18
|
-
.modebtn:hover { border-color
|
|
19
|
-
.modebtn.active { background
|
|
20
|
-
.modebtn.custom { margin-left:10px; border-style:dashed; color
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
.
|
|
24
|
-
.
|
|
25
|
-
.
|
|
26
|
-
.
|
|
27
|
-
.filters {
|
|
28
|
-
.filters summary { cursor:pointer; color:#8aa0b4; font-size:12px; font-weight:600; padding:6px 0; }
|
|
29
|
-
.filters .fsec { color:#5f7186; font-size:10.5px; text-transform:uppercase; letter-spacing:.06em; margin:12px 0 6px; }
|
|
55
|
+
.conn.connected { color:var(--accent); } .conn.waiting { color:var(--warn); }
|
|
56
|
+
.reconnect { background:var(--surface); border:1px solid var(--warn); color:var(--warn); border-radius:8px; padding:4px 12px; font:inherit; font-size:12px; font-weight:600; cursor:pointer; }
|
|
57
|
+
.reconnect:hover { border-color:var(--warn-2); } .reconnect:disabled { opacity:.6; cursor:default; }
|
|
58
|
+
.profilewrap { font-size:12px; color:var(--blue-2); } .profilewrap b { color:var(--text); }
|
|
59
|
+
.themebtn, .screenbtn { background:var(--surface); border:1px solid var(--border-2); color:var(--muted-2); border-radius:8px; padding:5px 12px; font:inherit; font-size:12.5px; cursor:pointer; }
|
|
60
|
+
.themebtn:hover, .screenbtn:hover { border-color:var(--accent); color:var(--text); } .screenbtn.on { background:var(--accent-bg); border-color:var(--accent); color:var(--accent-text); }
|
|
61
|
+
.modebar { display:flex; gap:6px; margin:0 0 6px; flex-wrap:wrap; flex:0 0 auto; }
|
|
62
|
+
.modebtn { background:var(--surface); border:1px solid var(--border-2); color:var(--muted-2); border-radius:8px; padding:6px 14px; font:inherit; font-size:12.5px; cursor:pointer; }
|
|
63
|
+
.modebtn:hover { border-color:var(--accent); color:var(--text); }
|
|
64
|
+
.modebtn.active { background:var(--accent-bg); border-color:var(--accent); color:var(--accent-text); font-weight:600; }
|
|
65
|
+
.modebtn.custom { margin-left:10px; border-style:dashed; color:var(--purple); } .modebtn.custom.active { background:var(--accent-bg); border-color:var(--purple); color:var(--purple); }
|
|
66
|
+
.sub { margin:0 0 6px; flex:0 0 auto; } .sub .summary { display:block; color:var(--text-2); font-size:12px; font-weight:600; } .sub .tech { display:none; }
|
|
67
|
+
/* filters — slim when collapsed */
|
|
68
|
+
.filters { max-width:1100px; margin-bottom:6px; flex:0 0 auto; }
|
|
69
|
+
.filters[open] { background:var(--surface-2); border:1px solid var(--border); border-radius:10px; padding:4px 12px; }
|
|
70
|
+
.filters summary { cursor:pointer; color:var(--muted); font-size:11px; font-weight:600; padding:2px 0; list-style:none; }
|
|
71
|
+
.filters summary::before { content:"\25B8 "; } .filters[open] summary::before { content:"\25BE "; }
|
|
72
|
+
.filters .fsec { color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.06em; margin:12px 0 6px; }
|
|
30
73
|
.filters .frow { display:flex; gap:8px; align-items:center; margin:6px 0; }
|
|
31
|
-
.filters .frow label { color
|
|
32
|
-
.filters input, .filters select { background
|
|
33
|
-
.filters input
|
|
34
|
-
.
|
|
35
|
-
.
|
|
36
|
-
.
|
|
37
|
-
.
|
|
38
|
-
.
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
.
|
|
42
|
-
.
|
|
43
|
-
.
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
.
|
|
50
|
-
.
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
.
|
|
56
|
-
.
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
.
|
|
60
|
-
.
|
|
61
|
-
.
|
|
62
|
-
.
|
|
63
|
-
.
|
|
64
|
-
.
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
.
|
|
68
|
-
.
|
|
69
|
-
.
|
|
70
|
-
.
|
|
71
|
-
.
|
|
72
|
-
.
|
|
73
|
-
.
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.
|
|
79
|
-
.
|
|
80
|
-
.
|
|
81
|
-
.
|
|
82
|
-
.
|
|
83
|
-
.
|
|
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
|
-
|
|
74
|
+
.filters .frow label { color:var(--muted); font-size:12px; min-width:96px; }
|
|
75
|
+
.filters input, .filters select { background:var(--surface); border:1px solid var(--border-2); border-radius:7px; color:var(--text); padding:6px 9px; font:inherit; font-size:12px; }
|
|
76
|
+
.filters input:focus, .filters select:focus { outline:none; border-color:var(--accent); }
|
|
77
|
+
.matchrow .f_mk { flex:0 0 130px; } .combo { position:relative; flex:1; display:flex; } .combo .f_mv { flex:1; padding-right:24px; }
|
|
78
|
+
.combocaret { position:absolute; right:8px; top:50%; transform:translateY(-50%); color:var(--muted); font-size:10px; cursor:pointer; }
|
|
79
|
+
.combolist { position:absolute; top:100%; left:0; right:0; z-index:30; margin-top:2px; max-height:200px; overflow:auto; background:var(--surface); border:1px solid var(--border-3); border-radius:7px; box-shadow:0 6px 18px var(--shadow); }
|
|
80
|
+
.comboitem { padding:6px 10px; font-size:12px; color:var(--text); cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
81
|
+
.comboitem:hover { background:var(--sel-soft); color:var(--accent); }
|
|
82
|
+
.filters .fdel { background:none; border:none; color:var(--error); cursor:pointer; font-size:13px; padding:0 4px; }
|
|
83
|
+
.filters .fadd { background:none; border:1px dashed var(--border-3); color:var(--muted-2); border-radius:7px; padding:5px 10px; font:inherit; font-size:11.5px; cursor:pointer; margin-top:4px; }
|
|
84
|
+
.filters .fapply { background:var(--accent-bg); border:1px solid var(--accent); color:var(--accent-text); border-radius:8px; padding:7px 16px; font:inherit; font-size:12.5px; font-weight:600; cursor:pointer; }
|
|
85
|
+
.filters .fmsg { color:var(--muted); font-size:12px; } .filters .fhint { color:var(--muted); font-size:11.5px; font-style:italic; }
|
|
86
|
+
.chips { display:flex; flex-wrap:wrap; gap:7px; margin-bottom:6px; flex:0 0 auto; }
|
|
87
|
+
.chip { display:inline-flex; align-items:center; gap:7px; background:var(--surface-3); border:1px solid var(--border-3); color:var(--text-2); border-radius:999px; padding:4px 6px 4px 11px; font-size:12px; }
|
|
88
|
+
.chip b { color:var(--blue-2); font-weight:600; } .chip .v { color:var(--text); }
|
|
89
|
+
.chip .x { cursor:pointer; color:var(--muted-2); background:var(--surface); border-radius:50%; width:16px; height:16px; line-height:14px; text-align:center; font-size:11px; }
|
|
90
|
+
.chip .x:hover { color:#fff; background:var(--error-2); }
|
|
91
|
+
/* stats strip */
|
|
92
|
+
.statstrip { display:flex; align-items:center; gap:18px; margin-bottom:6px; flex:0 0 auto; min-height:0; }
|
|
93
|
+
.label { font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); }
|
|
94
|
+
.stat { display:inline-flex; align-items:baseline; gap:6px; white-space:nowrap; }
|
|
95
|
+
.statlabel { font-size:10px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
|
|
96
|
+
.statval { font-size:22px; font-weight:700; color:var(--text); font-variant-numeric:tabular-nums; line-height:1; }
|
|
97
|
+
.statunit { font-size:11px; color:var(--muted); }
|
|
98
|
+
.ministat { display:inline-flex; align-items:center; gap:8px; white-space:nowrap; }
|
|
99
|
+
.minispark { display:inline-flex; align-items:flex-end; gap:2px; height:24px; }
|
|
100
|
+
.minibar { width:4px; background:var(--accent); border-radius:1px 1px 0 0; min-height:2px; }
|
|
101
|
+
/* collapsible graph */
|
|
102
|
+
.cgraph { display:inline-flex; align-items:center; }
|
|
103
|
+
.cgraph-mini { display:inline-flex; align-items:center; gap:8px; cursor:pointer; padding:3px 8px; border:1px solid var(--border); border-radius:8px; background:var(--surface-2); }
|
|
104
|
+
.cgraph-mini:hover { border-color:var(--accent); }
|
|
105
|
+
.minigraph { width:120px; height:28px; display:block; }
|
|
106
|
+
.cgraph-vals { display:inline-flex; gap:10px; }
|
|
107
|
+
.cgraph-val { font-size:12px; font-weight:700; font-variant-numeric:tabular-nums; }
|
|
108
|
+
.cgraph-exp { color:var(--muted); font-size:12px; }
|
|
109
|
+
.cgraph-overlay { position:fixed; inset:0; z-index:55; background:var(--overlay); display:flex; align-items:center; justify-content:center; }
|
|
110
|
+
.cgraph-panel { background:var(--surface); border:1px solid var(--border-3); border-radius:12px; padding:14px 18px; width:min(720px,86vw); box-shadow:0 12px 40px var(--shadow); }
|
|
111
|
+
.cgraph-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
|
|
112
|
+
.cgraph-head .label { font-size:12px; }
|
|
113
|
+
.cgraph-collapse { cursor:pointer; color:var(--muted-2); border:1px solid var(--border-3); border-radius:6px; padding:1px 8px; }
|
|
114
|
+
.cgraph-collapse:hover { color:var(--error); border-color:var(--error); }
|
|
115
|
+
.graphcard { display:block; }
|
|
116
|
+
.graphcard > .label { display:none; }
|
|
117
|
+
.graphcard svg { width:100%; height:260px; display:block; background:var(--inset); border-radius:8px; }
|
|
118
|
+
.graphcard .legend { margin-top:8px; font-size:12px; } .graphcard .legend span { margin-right:12px; display:inline-block; }
|
|
119
|
+
/* stage: feed + inspector */
|
|
120
|
+
.stage { display:flex; gap:18px; margin-top:0; align-items:stretch; flex:1; min-height:0; }
|
|
121
|
+
.stage.split .feedwrap-next { flex:2 1 380px; min-width:0; } .stage.split .inspector { flex:1 1 300px; }
|
|
122
|
+
.feedwrap-next { flex:1; min-width:0; display:flex; flex-direction:column; min-height:0; }
|
|
123
|
+
.feedbar { display:flex; gap:8px; align-items:center; margin-bottom:8px; flex-wrap:wrap; flex:0 0 auto; }
|
|
124
|
+
.ffilterwrap { position:relative; flex:1; display:flex; min-width:140px; } .ffilterwrap .ffilter { flex:1; padding-right:26px; }
|
|
125
|
+
.feedbar input { background:var(--surface-2); border:1px solid var(--border-2); border-radius:8px; color:var(--text); padding:7px 10px; font:inherit; font-size:12.5px; }
|
|
126
|
+
.feedbar input:focus { outline:none; border-color:var(--accent); }
|
|
127
|
+
.ffilterclear { position:absolute; right:8px; top:50%; transform:translateY(-50%); cursor:pointer; color:var(--muted-2); background:var(--surface); border-radius:50%; width:16px; height:16px; line-height:14px; text-align:center; font-size:10px; }
|
|
128
|
+
.ffilterclear:hover { color:#fff; background:var(--error-2); }
|
|
129
|
+
.fbtn { background:var(--surface); border:1px solid var(--border-3); border-radius:8px; color:var(--text); padding:6px 10px; font:inherit; font-size:12px; cursor:pointer; white-space:nowrap; }
|
|
130
|
+
.fbtn:hover { border-color:var(--accent); } .fbtn:disabled { opacity:.4; cursor:default; } .fbtn.paused { color:var(--warn); border-color:var(--warn); }
|
|
131
|
+
.fcount { color:var(--muted); font-size:11.5px; white-space:nowrap; }
|
|
132
|
+
.fstatus { font-size:11.5px; font-weight:600; white-space:nowrap; }
|
|
133
|
+
.fstatus.st-live { color:var(--accent); } .fstatus.st-locked { color:var(--warn); } .fstatus.st-paused { color:var(--warn); }
|
|
134
|
+
.fsortlbl { color:var(--muted); font-size:12px; display:flex; align-items:center; gap:5px; white-space:nowrap; }
|
|
135
|
+
.feedbar select { background:var(--surface); border:1px solid var(--border-3); border-radius:8px; color:var(--text); padding:6px 8px; font:inherit; font-size:12px; cursor:pointer; max-width:200px; }
|
|
136
|
+
/* the virtualized viewport */
|
|
137
|
+
.vpbox { position:relative; flex:1; min-height:0; display:flex; }
|
|
138
|
+
.vp { position:relative; flex:1; min-height:0; overflow-y:auto; border:1px solid var(--border); border-radius:12px; background:var(--inset); user-select:none; -webkit-user-select:none; }
|
|
139
|
+
.spacer { position:relative; width:100%; }
|
|
140
|
+
.row { position:absolute; left:0; right:0; height:var(--row-h); line-height:var(--row-h); display:flex; gap:12px; align-items:center; padding:0 14px; white-space:nowrap; overflow:hidden; border-bottom:1px solid var(--rowline); cursor:default; font-size:12.5px; }
|
|
141
|
+
/* row state order matters: breach/failed give the red LEFT-BAR + a faint tint; the
|
|
142
|
+
selection/inspect backgrounds are declared AFTER so equal-specificity selection WINS the
|
|
143
|
+
background (fixes "a red >800ms or failed row showed no grey when selected"). */
|
|
144
|
+
.row.breach, .row.failed { background:rgba(255,59,48,.12); box-shadow:inset 3px 0 var(--error); }
|
|
145
|
+
.row:hover { background:var(--hover); }
|
|
146
|
+
.row.sel { background:var(--sel); } /* selection background beats breach/failed tint */
|
|
147
|
+
.row.inspecting { box-shadow:inset 3px 0 var(--accent); } /* inspected row marker (overrides the red bar) */
|
|
148
|
+
.row .t { color:var(--muted); } .dir-OUTGOING{color:var(--blue)} .dir-INCOMING{color:var(--purple)} .chan { color:var(--muted-2); }
|
|
149
|
+
.tchip { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; padding:1px 6px; border-radius:4px; }
|
|
150
|
+
/* network status code chip in the row */
|
|
151
|
+
.stchip { font-size:10px; font-weight:700; padding:1px 6px; border-radius:4px; font-variant-numeric:tabular-nums; }
|
|
152
|
+
.stchip.st2 { background:var(--chip-clog-bg); color:var(--accent); } .stchip.st3 { background:var(--lvl-info-bg); color:var(--blue); }
|
|
153
|
+
.stchip.st4 { background:var(--lvl-warn-bg); color:var(--warn-2); } .stchip.st5 { background:var(--lvl-err-bg); color:var(--error); }
|
|
154
|
+
.t-network{ background:var(--chip-net-bg); color:var(--blue); } .t-rtm{ background:var(--chip-rtm-bg); color:var(--purple); } .t-clog{ background:var(--chip-clog-bg); color:var(--accent); } .t-log{ background:var(--chip-log-bg); color:var(--chip-log-fg); }
|
|
155
|
+
.lvl { font-size:10px; font-weight:700; padding:1px 5px; border-radius:4px; }
|
|
156
|
+
.lvl-ERROR,.lvl-FATAL{ background:var(--lvl-err-bg); color:var(--error); } .lvl-WARN{ background:var(--lvl-warn-bg); color:var(--warn-2); } .lvl-INFO{ background:var(--lvl-info-bg); color:var(--blue); } .lvl-DEBUG,.lvl-VERBOSE{ background:var(--lvl-dbg-bg); color:var(--muted-2); }
|
|
157
|
+
.logpkg { color:var(--blue-2); background:var(--surface-3); font-size:10.5px; padding:1px 6px; border-radius:4px; white-space:nowrap; max-width:130px; overflow:hidden; text-overflow:ellipsis; }
|
|
158
|
+
.logtag { color:var(--muted-2); font-weight:600; } .logmsg { color:var(--text-2); overflow:hidden; text-overflow:ellipsis; }
|
|
159
|
+
.breachbadge { margin-left:auto; background:var(--lvl-err-bg); color:var(--error); font-weight:700; font-size:11.5px; padding:2px 9px; border-radius:999px; white-space:nowrap; }
|
|
160
|
+
.pill { position:absolute; left:50%; transform:translateX(-50%); top:10px; background:var(--sel); color:var(--text); border:1px solid var(--accent); border-radius:18px; padding:5px 14px; cursor:pointer; box-shadow:0 2px 10px var(--shadow); font-size:12px; z-index:6; }
|
|
161
|
+
.pill-bottom { top:auto; bottom:14px; background:var(--surface); border-color:var(--border-3); color:var(--muted-2); }
|
|
162
|
+
.pillkey { margin-left:6px; font-size:10.5px; opacity:.8; border:1px solid currentColor; border-radius:4px; padding:0 5px; font-variant-numeric:tabular-nums; }
|
|
163
|
+
/* inspector */
|
|
164
|
+
.inspector { flex:1 1 340px; min-width:280px; min-height:0; background:var(--surface-2); border:1px solid var(--border); border-radius:12px; padding:14px 18px; overflow:auto; }
|
|
165
|
+
.inspector-hd { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:8px; }
|
|
166
|
+
.inspector-hd .ttl { color:var(--text); font-size:13px; font-weight:600; word-break:break-word; }
|
|
167
|
+
.inspector-hd .x { cursor:pointer; color:var(--muted-2); border:1px solid var(--border-3); border-radius:6px; padding:1px 8px; flex:0 0 auto; }
|
|
168
|
+
.inspector-hd .x:hover { color:var(--error); border-color:var(--error); }
|
|
169
|
+
.inspector .empty { color:var(--muted); font-size:12.5px; }
|
|
170
|
+
/* header badges: the at-a-glance facts */
|
|
171
|
+
.ibadges { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:8px; }
|
|
172
|
+
.ibadge { font-size:11px; font-weight:600; padding:2px 8px; border-radius:999px; background:var(--surface-3); color:var(--text-2); white-space:nowrap; }
|
|
173
|
+
.ibadge.meth { background:var(--chip-net-bg); color:var(--blue); }
|
|
174
|
+
.ibadge.st2 { background:var(--chip-clog-bg); color:var(--accent); } .ibadge.st3 { background:var(--lvl-info-bg); color:var(--blue); }
|
|
175
|
+
.ibadge.st4 { background:var(--lvl-warn-bg); color:var(--warn-2); } .ibadge.st5 { background:var(--lvl-err-bg); color:var(--error); }
|
|
176
|
+
.ibadge.slow { background:var(--lvl-err-bg); color:var(--error); }
|
|
177
|
+
.ibadge.dir-OUTGOING { background:var(--chip-net-bg); color:var(--blue); } .ibadge.dir-INCOMING { background:var(--chip-rtm-bg); color:var(--purple); }
|
|
178
|
+
.ibadge.lvl-ERROR,.ibadge.lvl-FATAL { background:var(--lvl-err-bg); color:var(--error); } .ibadge.lvl-WARN { background:var(--lvl-warn-bg); color:var(--warn-2); } .ibadge.lvl-INFO { background:var(--lvl-info-bg); color:var(--blue); }
|
|
179
|
+
.iurl { font-size:11.5px; color:var(--text-2); background:var(--inset); border:1px solid var(--border); border-radius:6px; padding:5px 8px; margin-bottom:8px; word-break:break-all; }
|
|
180
|
+
.copybar { display:flex; gap:6px; margin-bottom:10px; flex-wrap:wrap; }
|
|
181
|
+
.copybtn { background:var(--copybtn-bg); border:1px solid var(--copybtn-border); color:var(--copybtn-fg); border-radius:6px; padding:3px 9px; font:inherit; font-size:11.5px; cursor:pointer; }
|
|
182
|
+
.copybtn:hover { border-color:var(--accent); color:var(--accent); }
|
|
183
|
+
.copynote { align-self:center; font-size:10.5px; color:var(--text-2); opacity:.85; cursor:help; }
|
|
184
|
+
.copynote::before { content:"⚠ "; }
|
|
185
|
+
/* collapsible sections */
|
|
186
|
+
.insec { border:1px solid var(--border); border-radius:8px; margin-bottom:8px; overflow:hidden; }
|
|
187
|
+
.insec-hd { display:flex; align-items:center; gap:7px; padding:6px 10px; cursor:pointer; background:var(--surface-2); }
|
|
188
|
+
.insec-hd:hover { background:var(--hover); }
|
|
189
|
+
.insec-caret { color:var(--muted); font-size:10px; width:10px; }
|
|
190
|
+
.insec-title { font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted-2); font-weight:600; }
|
|
191
|
+
.insec-sub { color:var(--muted); font-size:11px; margin-left:auto; }
|
|
192
|
+
.insec-body { padding:8px 10px; border-top:1px solid var(--border); }
|
|
193
|
+
.inspector pre.json, .inspector pre.logmsg-full { margin:0; white-space:pre-wrap; word-break:break-word; font-size:12px; line-height:1.45; color:var(--text-2); }
|
|
194
|
+
.inspector pre.logmsg-full { color:var(--text); }
|
|
195
|
+
/* JSON syntax tokens. The .inspector pre.json .j-* rules MUST out-specify the base
|
|
196
|
+
pre.json color or the tokens render monochrome. */
|
|
197
|
+
.j-key { color:var(--j-key); } .j-str { color:var(--j-str); } .j-num { color:var(--j-num); } .j-bool { color:var(--j-bool); } .j-null { color:var(--j-null); font-style:italic; }
|
|
198
|
+
.inspector pre.json .j-key { color:var(--j-key); } .inspector pre.json .j-str { color:var(--j-str); } .inspector pre.json .j-num { color:var(--j-num); } .inspector pre.json .j-bool { color:var(--j-bool); } .inspector pre.json .j-null { color:var(--j-null); }
|
|
199
|
+
/* key/value grid for headers */
|
|
200
|
+
.trunc { background:var(--lvl-warn-bg); border:1px solid var(--warn); color:var(--warn-2); border-radius:6px; padding:7px 9px; font-size:11.5px; line-height:1.5; margin-bottom:8px; }
|
|
201
|
+
.inspector pre.rawbody { color:var(--text-2); }
|
|
202
|
+
.kv { display:grid; grid-template-columns:auto 1fr; gap:2px 12px; font-size:11.5px; }
|
|
203
|
+
.kv-k { color:var(--muted-2); white-space:nowrap; } .kv-v { color:var(--text-2); word-break:break-all; }
|
|
204
|
+
/* alert */
|
|
205
|
+
#flash { position:fixed; inset:0; background:#ff3b30; opacity:0; pointer-events:none; z-index:50; transition:opacity .08s; } #flash.on { opacity:.38; }
|
|
206
|
+
#alertbanner { position:fixed; top:0; left:0; right:0; z-index:60; text-align:center; background:#ff3b30; color:#fff; font-weight:700; padding:10px; letter-spacing:.04em; box-shadow:0 2px 12px var(--shadow); display:flex; justify-content:center; gap:20px; }
|
|
207
|
+
#alertbanner .x { cursor:pointer; }
|
|
208
|
+
/* DB browser */
|
|
114
209
|
.dbview { display:flex; gap:16px; max-width:1180px; margin-top:8px; height:72vh; }
|
|
115
|
-
.dbside { flex:0 0 260px; overflow:auto; border:1px solid
|
|
116
|
-
.dbsec { color
|
|
117
|
-
.dbsec:not(:first-child){ margin-top:14px; }
|
|
210
|
+
.dbside { flex:0 0 260px; overflow:auto; border:1px solid var(--border); border-radius:10px; padding:12px; }
|
|
211
|
+
.dbsec { color:var(--muted); font-size:10.5px; text-transform:uppercase; letter-spacing:.06em; margin:6px 0 6px; } .dbsec:not(:first-child){ margin-top:14px; }
|
|
118
212
|
.dbitem { padding:6px 9px; border-radius:7px; cursor:pointer; font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
119
|
-
.dbitem:hover { background
|
|
120
|
-
.dbitem
|
|
213
|
+
.dbitem:hover { background:var(--hover-soft); } .dbitem.sel { background:var(--sel-soft); color:var(--accent); } .dbitem .rows { float:right; color:var(--muted); font-size:11px; }
|
|
214
|
+
.dbitem.kbd { outline:2px solid var(--accent); outline-offset:-2px; } /* keyboard-focused row */
|
|
121
215
|
.dbmain { flex:1; min-width:0; display:flex; flex-direction:column; }
|
|
122
216
|
.dbqbar { display:flex; gap:8px; margin-bottom:8px; }
|
|
123
|
-
.dbqbar input { flex:1; background
|
|
124
|
-
.dbqbar
|
|
125
|
-
.
|
|
126
|
-
.
|
|
127
|
-
.
|
|
128
|
-
.
|
|
129
|
-
.dbprofile
|
|
130
|
-
.
|
|
131
|
-
.
|
|
132
|
-
.dbrefresh { background:#1c2a3a; border:1px solid #2c3e52; border-radius:6px; color:#8aa0b4; padding:2px 8px; font:inherit; font-size:11px; cursor:pointer; margin-left:8px; }
|
|
133
|
-
.dbrefresh:hover { border-color:#39d98a; color:#39d98a; }
|
|
134
|
-
.dbout { flex:1; overflow:auto; border:1px solid #1e2a3a; border-radius:10px; }
|
|
217
|
+
.dbqbar input { flex:1; background:var(--surface); border:1px solid var(--border-2); border-radius:8px; color:var(--text); padding:8px 11px; font:inherit; font-size:12.5px; } .dbqbar input:focus { outline:none; border-color:var(--accent); }
|
|
218
|
+
.dbqbar button { background:var(--accent-bg); border:1px solid var(--accent); color:var(--accent-text); border-radius:8px; padding:0 16px; font:inherit; font-size:12.5px; font-weight:600; cursor:pointer; }
|
|
219
|
+
.dbmeta { color:var(--muted); font-size:11.5px; margin-bottom:8px; }
|
|
220
|
+
.dbprivacy { background:var(--lvl-warn-bg); border:1px solid var(--warn); color:var(--warn-2); border-radius:8px; padding:8px 11px; font-size:11.5px; line-height:1.5; margin-bottom:10px; }
|
|
221
|
+
.dbprofile { color:var(--blue-2); font-size:11.5px; margin-bottom:8px; } .dbprofile b { color:var(--text); }
|
|
222
|
+
.dbprofcap { color:var(--muted); text-transform:uppercase; letter-spacing:.06em; font-size:10px; }
|
|
223
|
+
.dbprofile select { background:var(--surface); border:1px solid var(--border-2); border-radius:6px; color:var(--text); padding:3px 6px; font:inherit; font-size:11px; margin-left:4px; }
|
|
224
|
+
.dbrefresh { background:var(--surface); border:1px solid var(--border-3); border-radius:6px; color:var(--muted-2); padding:2px 8px; font:inherit; font-size:11px; cursor:pointer; margin-left:8px; } .dbrefresh:hover { border-color:var(--accent); color:var(--accent); }
|
|
225
|
+
.dbout { flex:1; overflow:auto; border:1px solid var(--border); border-radius:10px; }
|
|
135
226
|
.dbout table { border-collapse:collapse; width:100%; font-size:12px; }
|
|
136
|
-
.dbout th { position:sticky; top:0; background
|
|
137
|
-
.dbout td { padding:6px 10px; border-bottom:1px solid
|
|
138
|
-
.dbout tr:hover td { background
|
|
139
|
-
.msg { color
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.screenbtn { background:#121925; border:1px solid #243244; color:#8aa0b4; border-radius:8px; padding:5px 12px; font:inherit; font-size:12.5px; cursor:pointer; margin-left:8px; }
|
|
143
|
-
.screenbtn:hover { border-color:#39d98a; color:#dfe7ef; }
|
|
144
|
-
.screenbtn.on { background:#1c3a2a; border-color:#39d98a; color:#39d98a; }
|
|
145
|
-
#screenpanel { position:fixed; top:0; right:0; bottom:0; width:340px; background:#0b1016; border-left:1px solid #1e2a3a; z-index:40; display:none; flex-direction:column; padding:12px; box-shadow:-4px 0 16px rgba(0,0,0,.4); overflow:auto; }
|
|
146
|
-
#screenpanel.on { display:flex; }
|
|
147
|
-
body.screenopen { padding-right:356px; }
|
|
227
|
+
.dbout th { position:sticky; top:0; background:var(--surface); color:var(--muted-2); text-align:left; padding:7px 10px; border-bottom:1px solid var(--border); white-space:nowrap; }
|
|
228
|
+
.dbout td { padding:6px 10px; border-bottom:1px solid var(--rowline); max-width:380px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
229
|
+
.dbout tr:hover td { background:var(--hover); }
|
|
230
|
+
.msg { color:var(--muted); font-size:12px; padding:10px; } .msg.err { color:var(--error); }
|
|
231
|
+
/* screen overlay */
|
|
232
|
+
#screenpanel { position:fixed; top:0; right:0; bottom:0; width:340px; background:var(--surface-2); border-left:1px solid var(--border); z-index:40; display:flex; flex-direction:column; padding:12px; box-shadow:-4px 0 16px var(--shadow); overflow:auto; }
|
|
148
233
|
#screenpanel .schead { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
|
|
149
|
-
#screenpanel .
|
|
150
|
-
#screenpanel .scbtn { background
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
.
|
|
156
|
-
.
|
|
157
|
-
.scconsent .scaccept { background:#1c3a2a; border:1px solid #39d98a; color:#39d98a; border-radius:6px; padding:6px 14px; font:inherit; font-size:12px; cursor:pointer; margin-top:10px; }
|
|
158
|
-
.scconsent .scaccept:hover { background:#24492f; }
|
|
159
|
-
.scinstall { background:#0e151e; border:1px solid #1e2a3a; border-radius:6px; padding:8px 10px; margin-top:8px; font-size:11.5px; color:#cfe3d6; }
|
|
160
|
-
.scinstall code { color:#7fd6a3; }
|
|
161
|
-
.scinstall .sccopy { background:#172a1f; border:1px solid #2c5a3e; color:#7fd6a3; border-radius:5px; padding:2px 8px; font:inherit; font-size:11px; cursor:pointer; margin-left:6px; }
|
|
234
|
+
#screenpanel .sctitle { font-size:12.5px; color:var(--text); font-weight:600; flex:1; }
|
|
235
|
+
#screenpanel .scbtn { background:var(--surface); border:1px solid var(--border-2); color:var(--muted-2); border-radius:6px; padding:3px 9px; font:inherit; font-size:11.5px; cursor:pointer; } #screenpanel .scbtn:hover { border-color:var(--accent); color:var(--text); } #screenpanel .scbtn.rec { color:var(--error); border-color:var(--error); }
|
|
236
|
+
#screenimg { width:100%; border:1px solid var(--border); border-radius:8px; background:#000; min-height:120px; }
|
|
237
|
+
#schint { font-size:11.5px; color:var(--muted); margin-top:8px; line-height:1.5; }
|
|
238
|
+
.scconsent { background:var(--surface); border:1px solid var(--border-3); border-radius:8px; padding:12px; font-size:12px; color:var(--text-2); line-height:1.55; } .scconsent b { color:var(--warn-2); }
|
|
239
|
+
.scconsent .scaccept { background:var(--accent-bg); border:1px solid var(--accent); color:var(--accent-text); border-radius:6px; padding:6px 14px; font:inherit; font-size:12px; cursor:pointer; margin-top:10px; display:block; } .scconsent .scaccept:hover { filter:brightness(1.1); }
|
|
240
|
+
.scinstall { background:var(--surface-2); border:1px solid var(--border); border-radius:6px; padding:8px 10px; margin-top:8px; font-size:11.5px; color:var(--text-2); } .scinstall code { color:var(--copybtn-fg); }
|
|
241
|
+
.scinstall .sccopy { background:var(--copybtn-bg); border:1px solid var(--copybtn-border); color:var(--copybtn-fg); border-radius:5px; padding:2px 8px; font:inherit; font-size:11px; cursor:pointer; margin-left:6px; }
|
|
162
242
|
</style>
|
|
163
|
-
<!--
|
|
164
|
-
|
|
165
|
-
|
|
243
|
+
<!-- No-build Preact: the import map resolves the bare `preact` + `preact/hooks` + `htm`
|
|
244
|
+
specifiers to the vendored ES modules (served by serveStatic from vendor/). No bundler,
|
|
245
|
+
no build step for consumers -- the `npx @slack/radar-mcp@latest` model is unchanged. -->
|
|
166
246
|
<script type="importmap">
|
|
167
|
-
{ "imports": {
|
|
168
|
-
|
|
247
|
+
{ "imports": {
|
|
248
|
+
"preact": "./vendor/preact.module.js",
|
|
249
|
+
"preact/hooks": "./vendor/hooks.module.js",
|
|
250
|
+
"htm": "./vendor/htm.module.js"
|
|
251
|
+
} }
|
|
252
|
+
</script>
|
|
253
|
+
</head>
|
|
169
254
|
<body>
|
|
170
|
-
<div id="
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
<div id="screenpanel">
|
|
174
|
-
<div class="schead">
|
|
175
|
-
<span class="sctitle">Device screen</span>
|
|
176
|
-
<button class="scbtn" id="screenshot" title="Full-resolution screenshot">📷</button>
|
|
177
|
-
<button class="scbtn" id="screenrec" title="Record to mp4">● rec</button>
|
|
178
|
-
<button class="scbtn" id="screenredetect" title="Re-detect the active display">⟳</button>
|
|
179
|
-
<button class="scbtn" id="screenclose" title="Close">✕</button>
|
|
180
|
-
</div>
|
|
181
|
-
<div id="screenbody"></div>
|
|
182
|
-
<div id="schint"></div>
|
|
183
|
-
</div>
|
|
184
|
-
<div class="modebar" id="modebar"></div>
|
|
185
|
-
<div class="status" id="status"></div>
|
|
186
|
-
<div class="sub" id="sub"></div>
|
|
187
|
-
<details class="filters" id="filterpanel" style="display:none">
|
|
188
|
-
<summary>Filters</summary>
|
|
189
|
-
<div id="filterbody"></div>
|
|
190
|
-
</details>
|
|
191
|
-
<div class="grid" id="cards"></div>
|
|
192
|
-
<div class="stage" id="stage">
|
|
193
|
-
<div class="feedwrap" id="feedwrap" style="flex:1; min-width:0;">
|
|
194
|
-
<div class="feedbar" id="feedbar" style="display:none">
|
|
195
|
-
<span class="ffilterwrap"><input id="ffilter" placeholder="filter rows…" autocomplete="off"/><span class="ffilterclear" id="ffilterclear" title="clear filter" style="display:none">✕</span></span>
|
|
196
|
-
<label class="fsortlbl" id="fpkgwrap" style="display:none">app <select id="fpkg" title="show only one app's log lines"></select></label>
|
|
197
|
-
<label class="fsortlbl">sort <select id="fsort"></select></label>
|
|
198
|
-
<button id="fpause" title="pause stream">⏸ live</button>
|
|
199
|
-
<span id="fcount" class="fcount"></span>
|
|
200
|
-
</div>
|
|
201
|
-
<div class="chips" id="chips" style="display:none"></div>
|
|
202
|
-
<div class="feed" id="feed"></div>
|
|
203
|
-
</div>
|
|
204
|
-
<div class="inspector" id="inspector" style="display:none"></div>
|
|
205
|
-
</div>
|
|
206
|
-
<div class="dbview" id="dbview" style="display:none">
|
|
207
|
-
<div class="dbside"><div class="dbsec">databases</div><div id="db_dbs" class="msg">loading…</div><div class="dbsec">tables</div><div id="db_tbls" class="msg">pick a database</div></div>
|
|
208
|
-
<div class="dbmain">
|
|
209
|
-
<div class="dbprivacy">Privacy: opening a database copies the on-device Slack store (message text, request/response bodies, user, channel, and team IDs) in plaintext to a temp file on this machine. The copy is read-only and auto-deleted when this dashboard stops. Review before sharing anything from here.</div>
|
|
210
|
-
<div class="dbqbar"><input id="db_sql" placeholder="SELECT * FROM table LIMIT 100 (read-only)" autocomplete="off"/><button id="db_run">Run</button></div>
|
|
211
|
-
<div class="dbmeta" id="db_meta"></div>
|
|
212
|
-
<div class="dbout" id="db_out"><div class="msg">pick a database, then a table</div></div>
|
|
213
|
-
</div>
|
|
214
|
-
</div>
|
|
215
|
-
<script>
|
|
216
|
-
// The web page is just pre-built dashboard TABS over the device Radar stream. No in-page
|
|
217
|
-
// Claude. Customization happens in a terminal Claude Code session (via the
|
|
218
|
-
// open_radar_dashboard MCP tool -> /setspec); when a pushed spec does not match any
|
|
219
|
-
// pre-built tab, a "Custom" tab appears and selects. The page polls /spec and renders
|
|
220
|
-
// whichever preset (or custom spec) is active.
|
|
221
|
-
const statusEl=document.getElementById('status');
|
|
222
|
-
function setStatus(state,msg){ statusEl.className='status '+(state==='error'?'err':state==='ready'?'ready':''); statusEl.textContent = msg||''; }
|
|
223
|
-
document.getElementById('reconnect').onclick=doReconnect;
|
|
224
|
-
|
|
225
|
-
// The live dashboard spec. Hoisted above the first renderModes() call so markActiveMode()
|
|
226
|
-
// can read it before build() first assigns it, without a TDZ ReferenceError. Reassigned by
|
|
227
|
-
// build(spec); the engine section below treats this as its single source of truth.
|
|
228
|
-
let SPEC=null;
|
|
229
|
-
|
|
230
|
-
// ---- pre-built dashboards: the product. one tab each. ----
|
|
231
|
-
// Presets use only generic placeholders. The dashboard learns the real endpoints,
|
|
232
|
-
// channels, and event names LIVE from the device stream; nothing internal is baked in.
|
|
233
|
-
const MODES=[
|
|
234
|
-
{key:'all', label:'Live', spec:{title:'Live',summary:'Live: network, RTM, analytics clogs, and Slack-app logcat in one timeline. (Logs filtered to the Slack app; clear the package chip for all apps.)',source:'all',viz:['counter','rate','sparkline','feed','inspector'],match:{package:'com.Slack.internal.debug'}}},
|
|
235
|
-
{key:'network',label:'Network',spec:{title:'Network',summary:'Network calls per second vs average response time, every call listed, slow ones flagged.',source:'network',viz:['counter','rate','graph','feed','inspector'],series:[{metric:'ratePerSec',label:'calls/s',unit:'/s'},{metric:'avg',field:'duration_ms',label:'avg ms',unit:'ms'}],highlight:{field:'duration_ms',op:'>',value:800,label:'slow (>800ms)'},rateWindowSec:10}},
|
|
236
|
-
{key:'rtm', label:'RTM', spec:{title:'RTM',summary:'Real-time websocket events as they happen.',source:'rtm',viz:['counter','rate','feed','inspector']}},
|
|
237
|
-
{key:'clog', label:'Clogs', spec:{title:'Clogs',summary:'Analytics clog events, click any to see its full payload.',source:'clog',viz:['counter','rate','feed','inspector']}},
|
|
238
|
-
{key:'log', label:'Logs', spec:{title:'Device Logs',summary:'Live Slack app logcat, color-coded by level. Clear the package chip for all apps, or switch app with the dropdown.',source:'log',viz:['counter','feed','inspector'],match:{package:'com.Slack.internal.debug'}}},
|
|
239
|
-
{key:'db', label:'Database', spec:{title:'Device DB',summary:'Browse the on-device SQLite databases (read-only snapshot).',source:'db'}},
|
|
240
|
-
];
|
|
241
|
-
// A spec is "custom" when it did NOT come from a MODES preset — i.e. a terminal Claude
|
|
242
|
-
// session pushed it via the open_radar_dashboard MCP tool. We detect that by comparing
|
|
243
|
-
// the live spec to the presets; a non-match shows the Custom tab.
|
|
244
|
-
let CUSTOM_ACTIVE=false; // is the live spec the custom (Claude-pushed) one RIGHT NOW?
|
|
245
|
-
let lastCustomSpec=null; // the most-recent custom spec; keeps the Custom tab present
|
|
246
|
-
// until a page refresh, so you can flip back to it.
|
|
247
|
-
// A spec pushed by THIS browser (a Filters-panel Apply, or a tab click) is a local edit, not
|
|
248
|
-
// a Claude-pushed custom dashboard — so it must NOT spawn a "Custom" tab. localPushKey holds
|
|
249
|
-
// the JSON of the next spec we expect back from /spec because we just pushed it; pollSpec sees
|
|
250
|
-
// it match and treats it as a local edit (stays on the current tab). Only a non-preset spec we
|
|
251
|
-
// did NOT push (an MCP open_radar_dashboard) becomes the Custom tab.
|
|
252
|
-
let localPushKey=null;
|
|
253
|
-
function markLocalPush(spec){ localPushKey=JSON.stringify(spec); }
|
|
254
|
-
function specMatchesMode(spec){ if(!spec||spec.blank) return null;
|
|
255
|
-
return MODES.find(m=>JSON.stringify(m.spec)===JSON.stringify(spec))?.key || null; }
|
|
256
|
-
function renderModes(){ const bar=document.getElementById('modebar'); if(!bar) return;
|
|
257
|
-
let html=MODES.map(m=>'<button class="modebtn" data-k="'+m.key+'">'+m.label+'</button>').join('');
|
|
258
|
-
// Custom tab persists once a custom spec has been pushed this page-load (lastCustomSpec),
|
|
259
|
-
// not just while it is the live spec — so switching to a pre-built tab does not lose it.
|
|
260
|
-
if(lastCustomSpec) html+='<button class="modebtn custom" data-k="__custom__" title="'+esc(lastCustomSpec.title||'custom')+' — pushed from a terminal Claude Code session; persists until refresh">✎ '+esc(lastCustomSpec.title||'Custom')+'</button>';
|
|
261
|
-
bar.innerHTML=html;
|
|
262
|
-
bar.querySelectorAll('.modebtn').forEach(b=>b.onclick=async()=>{
|
|
263
|
-
const k=b.dataset.k;
|
|
264
|
-
const spec = k==='__custom__' ? lastCustomSpec : MODES.find(x=>x.key===k)?.spec;
|
|
265
|
-
if(!spec) return;
|
|
266
|
-
setStatus('', k==='__custom__' ? 'showing: '+(lastCustomSpec.title||'Custom') : 'showing: '+MODES.find(x=>x.key===k).label);
|
|
267
|
-
try{ const r=await (await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)})).json();
|
|
268
|
-
if(!r.ok) setStatus('error','⚠ '+(r.error||'failed')); }catch{ setStatus('error','switch failed'); } });
|
|
269
|
-
markActiveMode();
|
|
270
|
-
}
|
|
271
|
-
function markActiveMode(){ const bar=document.getElementById('modebar'); if(!bar) return;
|
|
272
|
-
const activeKey = CUSTOM_ACTIVE ? '__custom__' : specMatchesMode(SPEC);
|
|
273
|
-
bar.querySelectorAll('.modebtn').forEach(b=>b.classList.toggle('active', b.dataset.k===activeKey)); }
|
|
274
|
-
renderModes();
|
|
275
|
-
|
|
276
|
-
// ---- keyboard navigation ----
|
|
277
|
-
// Up/Down (or vim j/k) move the selected feed row; Home/End jump to first/last.
|
|
278
|
-
// Left/Right (or vim h/l) switch tabs. All clamp at the ends — no wrap.
|
|
279
|
-
// Reuses inspect() for selection and the existing .modebtn click for tab switches,
|
|
280
|
-
// and reads the live DOM each press so it survives stream-driven re-renders.
|
|
281
|
-
function visibleRows(){
|
|
282
|
-
// feed is null on tabs without a feed viz; guard.
|
|
283
|
-
return feed ? [...feed.children].filter(r=>r.classList.contains('row') && !r.classList.contains('hidden')) : [];
|
|
284
|
-
}
|
|
285
|
-
function pickRow(r){
|
|
286
|
-
if(!r) return;
|
|
287
|
-
// inspect() toggles selection OFF when called on the already-selected row, so skip it.
|
|
288
|
-
if(!r.classList.contains('sel')) inspect(r,r._ev);
|
|
289
|
-
r.scrollIntoView({block:'nearest'});
|
|
290
|
-
}
|
|
291
|
-
function moveRow(dir){ // dir: -1 up, +1 down, 'first', 'last'
|
|
292
|
-
const rows=visibleRows();
|
|
293
|
-
if(rows.length===0) return;
|
|
294
|
-
if(dir==='first') return pickRow(rows[0]);
|
|
295
|
-
if(dir==='last') return pickRow(rows[rows.length-1]);
|
|
296
|
-
const cur=rows.findIndex(r=>r.classList.contains('sel'));
|
|
297
|
-
if(cur===-1) return pickRow(dir>0?rows[0]:rows[rows.length-1]);
|
|
298
|
-
const next=Math.max(0,Math.min(rows.length-1,cur+dir)); // clamp
|
|
299
|
-
pickRow(rows[next]);
|
|
300
|
-
}
|
|
301
|
-
function moveTab(dir){ // dir: -1 left, +1 right; clamp
|
|
302
|
-
const btns=[...document.querySelectorAll('#modebar .modebtn')];
|
|
303
|
-
if(btns.length===0) return;
|
|
304
|
-
let cur=btns.findIndex(b=>b.classList.contains('active'));
|
|
305
|
-
if(cur===-1) cur=0;
|
|
306
|
-
const next=Math.max(0,Math.min(btns.length-1,cur+dir));
|
|
307
|
-
if(next!==cur) btns[next].click(); // fires existing onclick → /setspec → rebuild
|
|
308
|
-
}
|
|
309
|
-
document.addEventListener('keydown',(e)=>{
|
|
310
|
-
const t=e.target;
|
|
311
|
-
if(t && (t.tagName==='INPUT' || t.tagName==='TEXTAREA' || t.isContentEditable)) return;
|
|
312
|
-
switch(e.key){
|
|
313
|
-
case 'ArrowDown': case 'j': moveRow(+1); break;
|
|
314
|
-
case 'ArrowUp': case 'k': moveRow(-1); break;
|
|
315
|
-
case 'ArrowRight': case 'l': moveTab(+1); break;
|
|
316
|
-
case 'ArrowLeft': case 'h': moveTab(-1); break;
|
|
317
|
-
case 'Home': moveRow('first'); break;
|
|
318
|
-
case 'End': moveRow('last'); break;
|
|
319
|
-
default: return;
|
|
320
|
-
}
|
|
321
|
-
e.preventDefault(); // stop the list/page from also scrolling
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
// ---- poll /spec; rebuild when it changes, and show/hide the Custom tab accordingly ----
|
|
325
|
-
let specKey='';
|
|
326
|
-
async function pollSpec(){ try{ const spec=await (await fetch('/spec')).json(); const key=JSON.stringify(spec); if(key===specKey) return; specKey=key;
|
|
327
|
-
// A non-blank spec that matches no preset is a Claude-pushed custom dashboard — UNLESS this
|
|
328
|
-
// browser just pushed it (a Filters-panel Apply or a tab click), which is a local edit and
|
|
329
|
-
// must stay on the current tab, not spawn a Custom tab.
|
|
330
|
-
const isLocalEdit = key===localPushKey; if(isLocalEdit) localPushKey=null;
|
|
331
|
-
CUSTOM_ACTIVE = !isLocalEdit && !!spec && !spec.blank && specMatchesMode(spec)===null;
|
|
332
|
-
let needRerender=false;
|
|
333
|
-
if(CUSTOM_ACTIVE){ // remember it so the tab persists; re-render if it's new/changed
|
|
334
|
-
if(!lastCustomSpec || JSON.stringify(lastCustomSpec)!==JSON.stringify(spec)){ lastCustomSpec=spec; needRerender=true; }
|
|
335
|
-
}
|
|
336
|
-
if(needRerender) renderModes(); // add or update the Custom tab (label = its title)
|
|
337
|
-
build(spec);
|
|
338
|
-
if(CUSTOM_ACTIVE) setStatus('ready','Custom dashboard (pushed from a Claude Code session): '+(spec.title||''));
|
|
339
|
-
else { const k=specMatchesMode(spec); const m=MODES.find(x=>x.key===k); if(m) setStatus('','showing: '+m.label); }
|
|
340
|
-
markActiveMode();
|
|
341
|
-
}catch{} }
|
|
342
|
-
setInterval(pollSpec,700);
|
|
343
|
-
// Default to the Live dashboard on load (instead of a blank canvas).
|
|
344
|
-
async function bootDefault(){ try{ const cur=await (await fetch('/spec')).json();
|
|
345
|
-
if(!cur||cur.blank){ const live=MODES[0]; await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(live.spec)}).catch(()=>{}); }
|
|
346
|
-
}catch{} }
|
|
347
|
-
bootDefault();
|
|
348
|
-
// Connection dot driven by an always-on device health check, independent of whether
|
|
349
|
-
// a dashboard is built (a blank dashboard has no stream, so the dot was otherwise dead).
|
|
350
|
-
let lastEventTs=0, healthMisses=0;
|
|
351
|
-
// Clear a stale red "device unreachable / backfill failed" status once health is back. These
|
|
352
|
-
// errors are set once (e.g. at build/backfill time) and were otherwise sticky until a tab
|
|
353
|
-
// switch — so a transient blip left the red banner up long after the device recovered.
|
|
354
|
-
function clearStaleError(){ if(statusEl.classList.contains('err')) setStatus('',''); }
|
|
355
|
-
async function pollHealth(){
|
|
356
|
-
// Recent stream activity proves liveness without a /health round-trip.
|
|
357
|
-
if(Date.now()-lastEventTs<6000){ healthMisses=0; setConn('connected',''); clearStaleError(); return; }
|
|
358
|
-
try{ const h=await (await fetch('/health')).json();
|
|
359
|
-
if(h.device){ healthMisses=0; setConn('connected',''); clearStaleError(); }
|
|
360
|
-
// Require TWO consecutive misses before flipping the dot — a single slow/missed poll
|
|
361
|
-
// (while the stream is otherwise fine) should not flash "connecting".
|
|
362
|
-
else if(++healthMisses>=2){ setConn('waiting','device offline, retrying…'); }
|
|
363
|
-
}catch{ if(++healthMisses>=2) setConn('waiting','reconnecting…'); } }
|
|
364
|
-
pollHealth(); setInterval(pollHealth,2000);
|
|
365
|
-
showConnectedProfile(); setInterval(showConnectedProfile,5000); // keep the connected-profile label honest as the socket re-binds
|
|
366
|
-
|
|
367
|
-
// ---- the dashboard engine: build(spec) tears down and rebuilds widgets live ----
|
|
368
|
-
let es=null, tickTimer=null;
|
|
369
|
-
let total=0, times=[], perSec=[], counterEl=null, rateEl=null, sparkEl=null, feed=null, cards=null, GRAPH=null;
|
|
370
|
-
// liveMatches counts ONLY genuine live arrivals (not backfilled/replayed history), so the
|
|
371
|
-
// alert {at:N} "fire once at the Nth match" threshold is measured from when the dashboard
|
|
372
|
-
// opened — independent of how many matching events the device buffer already held. `total`
|
|
373
|
-
// stays history-inclusive because the counter widget should show every event seen.
|
|
374
|
-
let liveMatches=0;
|
|
375
|
-
function has(v){ return SPEC.viz.includes(v); }
|
|
376
|
-
function showInspector(){ return has('feed') || has('inspector'); }
|
|
377
|
-
function build(spec){
|
|
378
|
-
SPEC=spec; total=0; liveMatches=0; times=[]; perSec=new Array(30).fill(0);
|
|
379
|
-
markActiveMode();
|
|
380
|
-
cards=document.getElementById('cards'); cards.innerHTML=''; feed=document.getElementById('feed'); feed.innerHTML='';
|
|
381
|
-
counterEl=rateEl=sparkEl=null;
|
|
382
|
-
const insp=document.getElementById('inspector'), stage=document.getElementById('stage');
|
|
383
|
-
if(es){ es.close(); es=null; } if(tickTimer){ clearInterval(tickTimer); tickTimer=null; }
|
|
384
|
-
const dbv=document.getElementById('dbview');
|
|
385
|
-
const fp=document.getElementById('filterpanel');
|
|
386
|
-
if(SPEC.blank){ document.getElementById('sub').textContent='Pick a dashboard above (Live, Network, RTM, Clogs, Logs, Database). For a custom view, ask a Claude Code session in your terminal to build one — it appears here as a Custom tab.'; insp.style.display='none'; stage.classList.remove('split'); if(fp) fp.style.display='none'; stage.style.display='flex'; if(dbv) dbv.style.display='none'; return; }
|
|
387
|
-
if(SPEC.source==='db'){
|
|
388
|
-
// The DB browser is a distinct, point-in-time view (no stream/widgets/filters). Hide the
|
|
389
|
-
// streaming UI + the Filters panel, show the db list -> tables -> query grid.
|
|
390
|
-
stage.style.display='none'; cards.style.display='none'; insp.style.display='none';
|
|
391
|
-
if(fp) fp.style.display='none';
|
|
392
|
-
const subEl=document.getElementById('sub');
|
|
393
|
-
subEl.innerHTML='<span class="summary">'+esc(SPEC.summary||'Device database browser')+'</span><span class="tech">source=db (on-device SQLite snapshot, read-only)</span>';
|
|
394
|
-
if(dbv) dbv.style.display='flex';
|
|
395
|
-
buildDbView(SPEC);
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
if(dbv) dbv.style.display='none';
|
|
399
|
-
document.getElementById('stage').style.display='flex'; cards.style.display='grid';
|
|
400
|
-
if(showInspector()){ insp.style.display='block'; insp.innerHTML='<div class="label">inspector</div><div class="empty">click a row to see its full detail</div>'; }
|
|
401
|
-
else { insp.style.display='none'; }
|
|
402
|
-
const split = showInspector();
|
|
403
|
-
stage.classList.toggle('split', split); stage.classList.toggle('haswidth', split);
|
|
404
|
-
setupFeedControls();
|
|
405
|
-
const subEl=document.getElementById('sub');
|
|
406
|
-
const tech=describeConstraints(SPEC);
|
|
407
|
-
subEl.innerHTML = SPEC.summary
|
|
408
|
-
? '<span class="summary">'+esc(SPEC.summary)+'</span><span class="tech">'+esc(tech)+'</span>'
|
|
409
|
-
: esc(SPEC.title+' — '+tech);
|
|
410
|
-
renderFilters();
|
|
411
|
-
if(has('counter')){ const c=card('total matched', big('0','counterEl')); c.querySelector('.label').id='counterLabel'; cards.appendChild(c); }
|
|
412
|
-
if(has('rate')){ cards.appendChild(card('rate ('+(SPEC.rateWindowSec||10)+'s)', big('0','rateEl','/'+(SPEC.rateWindowSec||10)+'s'))); }
|
|
413
|
-
if(has('sparkline')){ const c=card('per second',''); const s=document.createElement('div'); s.className='spark'; s.id='spark'; c.appendChild(s); cards.appendChild(c); }
|
|
414
|
-
counterEl=document.getElementById('counterEl'); rateEl=document.getElementById('rateEl'); sparkEl=document.getElementById('spark');
|
|
415
|
-
GRAPH=null;
|
|
416
|
-
if(has('graph') && Array.isArray(SPEC.series)){
|
|
417
|
-
const N=60;
|
|
418
|
-
GRAPH={ series: SPEC.series.map(s=>({def:s, buckets:new Array(N).fill(0), counts:new Array(N).fill(0)})), N };
|
|
419
|
-
const wrap=document.createElement('div'); wrap.className='graphcard';
|
|
420
|
-
wrap.innerHTML='<div class="label">'+esc(SPEC.series.map(s=>s.label||s.metric).join(' vs '))+'</div><svg id="graph" viewBox="0 0 640 200"></svg><div class="legend" id="legend"></div>';
|
|
421
|
-
cards.appendChild(wrap);
|
|
422
|
-
}
|
|
423
|
-
if(es) es.close();
|
|
424
|
-
es=new EventSource('/stream'); es.onmessage=onEvent;
|
|
425
|
-
es.onerror=()=>{ setConn('waiting','reconnecting…'); };
|
|
426
|
-
if(tickTimer) clearInterval(tickTimer); tickTimer=setInterval(tick,1000);
|
|
427
|
-
replayCache(); // instant: everything seen this session for this source (incl logs)
|
|
428
|
-
backfill(); // device ring-buffer history from before this session; dedup avoids double-count
|
|
429
|
-
}
|
|
430
|
-
// Pre-populate a freshly-built tab from the device ring-buffer (the source of truth), so
|
|
431
|
-
// switching tabs shows recent data immediately instead of resetting to nothing. Logcat is
|
|
432
|
-
// host-side (no device buffer) so "log"-only tabs have nothing to backfill — they still
|
|
433
|
-
// stream live. Each replayed frame goes through onEvent with live=false, so it populates
|
|
434
|
-
// widgets but does not fire alerts (those are reserved for genuine /stream arrivals).
|
|
435
|
-
let backfillToken=0;
|
|
436
|
-
async function backfill(){
|
|
437
|
-
const myToken=++backfillToken; const src=SPEC.source;
|
|
438
|
-
if(!src) return;
|
|
439
|
-
if(src==='log'){ setStatus('','live device logs — history is not buffered on-device, so this tab fills as new lines arrive'); return; }
|
|
440
|
-
try{ const r=await (await fetch('/backfill?source='+encodeURIComponent(src)+'&limit=200')).json();
|
|
441
|
-
if(myToken!==backfillToken) return;
|
|
442
|
-
const frames=(r&&r.frames)||[];
|
|
443
|
-
// live=false: backfilled frames are device HISTORY (events from before this tab opened),
|
|
444
|
-
// so they count toward widgets but must NOT fire alerts — only genuine /stream arrivals do.
|
|
445
|
-
for(const f of frames) onEvent({data:JSON.stringify(f)}, false);
|
|
446
|
-
if(frames.length===0){
|
|
447
|
-
try{ const h=await (await fetch('/health')).json();
|
|
448
|
-
if(!h.device) setStatus('error','no history loaded — device unreachable. Wake/replug the phone, then ⟳ Reconnect Radar');
|
|
449
|
-
else setStatus('','no recent '+src+' activity buffered on the device yet — interact with Slack and it will stream in');
|
|
450
|
-
}catch{ setStatus('error','no history loaded — device unreachable. ⟳ Reconnect Radar'); }
|
|
451
|
-
}
|
|
452
|
-
}catch{ setStatus('error','backfill failed — device unreachable?'); }
|
|
453
|
-
}
|
|
454
|
-
function matches(ev){ if(!SPEC.match) return true;
|
|
455
|
-
return Object.entries(SPEC.match).every(([k,v])=>{
|
|
456
|
-
// the package field only exists on LOG events. In the unified "all" timeline, applying
|
|
457
|
-
// it to network/rtm/clog (which have no package) would wrongly drop them — they already
|
|
458
|
-
// come from the Slack app. So a package filter constrains log rows only; others pass.
|
|
459
|
-
if(k==='package' && ev._type && ev._type!=='log') return true;
|
|
460
|
-
return String(ev[k]??'').toLowerCase().includes(String(v).toLowerCase());
|
|
461
|
-
}); }
|
|
462
|
-
|
|
463
|
-
// ---- hand-editable Filters panel: renders the current spec's filters as form controls,
|
|
464
|
-
// edits POST back to /setspec, engine rebuilds live ----
|
|
465
|
-
const MATCH_FIELDS={ network:['url','method','status_code'], rtm:['event_type','direction','channel'], clog:['event_name'],
|
|
466
|
-
log:['tag','level','message','pid','package'], all:['url','method','status_code','event_type','direction','channel','event_name','tag','level','message','pid','package'] };
|
|
467
|
-
const FIELD_VALUES={ direction:['OUTGOING','INCOMING'], level:['VERBOSE','DEBUG','INFO','WARN','ERROR','FATAL'], method:['GET','POST','PUT','DELETE','PATCH'] };
|
|
468
|
-
function candidatesFor(field){
|
|
469
|
-
if(FIELD_VALUES[field]) return FIELD_VALUES[field].slice();
|
|
470
|
-
const seen=SEEN[field]; return seen ? [...seen].sort() : [];
|
|
471
|
-
}
|
|
472
|
-
function valueControlHTML(field,val){
|
|
473
|
-
const n=candidatesFor(field).length;
|
|
474
|
-
const ph = FIELD_VALUES[field] ? 'pick or type…' : (n? 'type or pick ('+n+' seen)' : 'contains…');
|
|
475
|
-
return '<span class="combo"><input class="f_mv" autocomplete="off" data-field="'+esc(field)+'" value="'+esc(String(val).trim())+'" placeholder="'+ph+'"/><span class="combocaret">▾</span><div class="combolist"></div></span>';
|
|
476
|
-
}
|
|
477
|
-
function renderFilters(){
|
|
478
|
-
const panel=document.getElementById('filterpanel'), bodyEl=document.getElementById('filterbody');
|
|
479
|
-
if(!panel||SPEC.blank){ if(panel) panel.style.display='none'; return; }
|
|
480
|
-
if(!panel._wired){ panel._wired=true; panel.addEventListener('toggle',()=>{ if(panel.open) renderFilters(); }); }
|
|
481
|
-
panel.style.display='block';
|
|
482
|
-
const fields=MATCH_FIELDS[SPEC.source]||[];
|
|
483
|
-
const m=SPEC.match&&typeof SPEC.match==='object'?SPEC.match:{};
|
|
484
|
-
let h='';
|
|
485
|
-
h+='<div class="frow"><label>source</label><select id="f_source">'+
|
|
486
|
-
['rtm','network','clog','log','all'].map(s=>'<option'+(s===SPEC.source?' selected':'')+'>'+s+'</option>').join('')+'</select></div>';
|
|
487
|
-
h+='<div class="fsec">match (all must hold)</div><div id="f_matchrows">';
|
|
488
|
-
const entries=Object.entries(m); if(!entries.length) h+='<div class="fhint">no match filter — showing everything</div>';
|
|
489
|
-
for(const [k,v] of entries){ h+=matchRow(k,v,fields); }
|
|
490
|
-
h+='</div><button class="fadd" id="f_addmatch">+ add match field</button>';
|
|
491
|
-
if(SPEC.source==='network'||SPEC.source==='all')
|
|
492
|
-
h+='<div class="frow"><label>body contains</label><input id="f_body" value="'+esc(SPEC.bodyContains||'')+'" placeholder="substring in request/response body"/></div>';
|
|
493
|
-
const hl=SPEC.highlight||{};
|
|
494
|
-
h+='<div class="fsec">highlight (flag rows)</div><div class="frow">'+
|
|
495
|
-
'<input id="f_hlfield" value="'+esc(hl.field||'')+'" placeholder="field e.g. duration_ms" style="flex:2"/>'+
|
|
496
|
-
'<select id="f_hlop">'+['>','>=','<','<=','=='].map(o=>'<option'+(o===(hl.op||'>')?' selected':'')+'>'+o+'</option>').join('')+'</select>'+
|
|
497
|
-
'<input id="f_hlval" value="'+(hl.value!=null?hl.value:'')+'" placeholder="value" style="flex:1"/></div>';
|
|
498
|
-
h+='<div class="frow"><label>rate window (s)</label><input id="f_rate" type="number" value="'+(SPEC.rateWindowSec||10)+'" style="flex:1"/></div>';
|
|
499
|
-
h+='<div class="frow"><button class="fapply" id="f_apply">Apply changes</button><span id="f_msg" class="fmsg"></span></div>';
|
|
500
|
-
bodyEl.innerHTML=h;
|
|
501
|
-
bodyEl.querySelectorAll('.matchrow').forEach(wireMatchRow);
|
|
502
|
-
document.getElementById('f_addmatch').onclick=()=>{ const d=document.createElement('div'); d.innerHTML=matchRow(fields[0]||'','',fields); const row=d.firstChild; document.getElementById('f_matchrows').appendChild(row); wireMatchRow(row); };
|
|
503
|
-
document.getElementById('f_apply').onclick=applyFilters;
|
|
504
|
-
}
|
|
505
|
-
function matchRow(k,v,fields){
|
|
506
|
-
const opts=fields.map(f=>'<option'+(f===k?' selected':'')+'>'+f+'</option>').join('');
|
|
507
|
-
return '<div class="frow matchrow"><select class="f_mk">'+opts+(fields.includes(k)?'':'<option selected>'+esc(k)+'</option>')+
|
|
508
|
-
'</select><span class="f_mvwrap">'+valueControlHTML(k,v)+'</span><button class="fdel">✕</button></div>';
|
|
509
|
-
}
|
|
510
|
-
function wireMatchRow(row){
|
|
511
|
-
const mk=row.querySelector('.f_mk'), wrap=row.querySelector('.f_mvwrap');
|
|
512
|
-
mk.onchange=()=>{ wrap.innerHTML=valueControlHTML(mk.value,''); wireCombo(wrap.querySelector('.combo')); };
|
|
513
|
-
row.querySelector('.fdel').onclick=()=>row.remove();
|
|
514
|
-
wireCombo(wrap.querySelector('.combo'));
|
|
515
|
-
}
|
|
516
|
-
function wireCombo(combo){
|
|
517
|
-
if(!combo) return;
|
|
518
|
-
const input=combo.querySelector('.f_mv'), caret=combo.querySelector('.combocaret'), list=combo.querySelector('.combolist');
|
|
519
|
-
const field=input.getAttribute('data-field');
|
|
520
|
-
const openList=()=>{ const q=input.value.trim().toLowerCase();
|
|
521
|
-
const cands=candidatesFor(field).filter(v=>!q||String(v).toLowerCase().includes(q)).slice(0,50);
|
|
522
|
-
if(!cands.length){ list.classList.remove('open'); return; }
|
|
523
|
-
list.innerHTML=cands.map(v=>'<div class="comboitem">'+esc(v)+'</div>').join('');
|
|
524
|
-
list.classList.add('open');
|
|
525
|
-
list.querySelectorAll('.comboitem').forEach(it=>it.onmousedown=(e)=>{ e.preventDefault(); input.value=it.textContent; list.classList.remove('open'); });
|
|
526
|
-
};
|
|
527
|
-
input.onfocus=openList; input.oninput=openList;
|
|
528
|
-
caret.onmousedown=(e)=>{ e.preventDefault(); if(list.classList.contains('open')) list.classList.remove('open'); else { input.focus(); openList(); } };
|
|
529
|
-
input.onblur=()=>setTimeout(()=>list.classList.remove('open'),120);
|
|
530
|
-
}
|
|
531
|
-
async function applyFilters(){
|
|
532
|
-
const g=id=>document.getElementById(id);
|
|
533
|
-
const spec=JSON.parse(JSON.stringify(SPEC));
|
|
534
|
-
spec.source=g('f_source').value;
|
|
535
|
-
const match={}; document.querySelectorAll('#f_matchrows .matchrow').forEach(r=>{ const k=r.querySelector('.f_mk').value.trim(); const v=r.querySelector('.f_mv').value.trim(); if(k&&v) match[k]=v; });
|
|
536
|
-
spec.match=match;
|
|
537
|
-
const body=g('f_body'); if(body){ const bv=body.value.trim(); if(bv) spec.bodyContains=bv; else delete spec.bodyContains; }
|
|
538
|
-
const hf=g('f_hlfield').value.trim(), hv=g('f_hlval').value.trim();
|
|
539
|
-
if(hf&&hv!==''&&!isNaN(Number(hv))) spec.highlight={field:hf,op:g('f_hlop').value,value:Number(hv),label:(SPEC.highlight&&SPEC.highlight.label)||hf}; else delete spec.highlight;
|
|
540
|
-
const rw=Number(g('f_rate').value); if(rw>0) spec.rateWindowSec=rw;
|
|
541
|
-
const msg=g('f_msg'); msg.textContent='applying…';
|
|
542
|
-
markLocalPush(spec); // a hand-edit of the current view, not a Claude-pushed Custom dashboard
|
|
543
|
-
try{ const r=await (await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)})).json();
|
|
544
|
-
msg.textContent = r.ok?'applied ✓':('⚠ '+(r.error||'rejected')); }
|
|
545
|
-
catch{ msg.textContent='⚠ failed'; }
|
|
546
|
-
}
|
|
547
|
-
function pluck(o,p){ return p.split('.').reduce((x,k)=>(x==null?undefined:x[k]),o); }
|
|
548
|
-
async function enrich(ev){ if(!SPEC.extract||ev.id==null) return null; try{ const kind=SPEC.extract.kind||SPEC.source; const d=await (await fetch('/detail?kind='+kind+'&id='+ev.id)).json(); let src=d[SPEC.extract.parse||'response_body']; if(typeof src==='string'){ try{src=JSON.parse(src);}catch{} } return pluck(src,SPEC.extract.field); }catch{ return null; } }
|
|
549
|
-
function setConn(state,msg){ const d=document.getElementById('conn'); if(!d) return; d.className='conn '+state; d.textContent=(state==='connected'?'● live':'● '+(msg||'reconnecting…'));
|
|
550
|
-
const rc=document.getElementById('reconnect'); if(rc && !rc._busy) rc.style.display = state==='connected' ? 'none' : 'inline-block'; }
|
|
551
|
-
async function doReconnect(){ const rc=document.getElementById('reconnect'); if(!rc) return; rc._busy=true; rc.disabled=true; rc.textContent='⟳ enabling…';
|
|
552
|
-
try{ await fetch('/enable',{method:'POST'}); }catch{}
|
|
553
|
-
rc._busy=false; rc.disabled=false; rc.textContent='⟳ Reconnect Radar'; showConnectedProfile(); pollHealth(); pollSpec(); }
|
|
554
|
-
|
|
555
|
-
// Show which device profile Radar is connected to. Read-only: on a phone running the
|
|
556
|
-
// debug build on more than one profile (personal user 0 + a work profile) only ONE
|
|
557
|
-
// instance binds the single Radar socket — whichever launched first wins, and a second
|
|
558
|
-
// profile cannot take it while the first holds it. So we cannot SWITCH profiles from here
|
|
559
|
-
// yet (that needs per-profile sockets, a device-side change); we just report the truth.
|
|
560
|
-
// The label is ALWAYS visible (operator: "profile must show even if one"): the bound
|
|
561
|
-
// profile comes from /health's boundUserId/boundProfile (the real socket owner). When the
|
|
562
|
-
// device drops, keep showing the last-known profile dimmed rather than vanishing.
|
|
563
|
-
let lastBoundLabel=null;
|
|
564
|
-
async function showConnectedProfile(){
|
|
565
|
-
const wrap=document.getElementById('profilewrap'), nameEl=document.getElementById('profilename');
|
|
566
|
-
if(!wrap||!nameEl) return;
|
|
567
|
-
try{
|
|
568
|
-
const [profilesR, healthR]=await Promise.all([
|
|
569
|
-
fetch('/_control/profiles').then(r=>r.json()).catch(()=>null),
|
|
570
|
-
fetch('/health').then(r=>r.json()).catch(()=>null),
|
|
571
|
-
]);
|
|
572
|
-
const uid=healthR&&healthR.boundUserId;
|
|
573
|
-
if(uid==null){
|
|
574
|
-
// Device not connected right now. Do NOT hide (the label is always present), but do
|
|
575
|
-
// NOT claim a current binding either: the socket may re-bind to a DIFFERENT profile
|
|
576
|
-
// while we are down, so "<last> (disconnected)" could name the wrong one. Phrase it as
|
|
577
|
-
// the last profile we SAW, not the one bound now. Cleared so a fresh connect re-resolves.
|
|
578
|
-
nameEl.textContent = lastBoundLabel ? 'last seen '+lastBoundLabel+' — reconnecting…' : 'waiting for device…';
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
const profiles=(profilesR&&profilesR.profiles)||[];
|
|
582
|
-
const match=profiles.find(p=>p.userId===uid);
|
|
583
|
-
// Fallback when the profile list does not name this uid (fetch failed, or ps and the
|
|
584
|
-
// live ping disagree). NEVER hard-code a user id: always print the REAL bound uid, and
|
|
585
|
-
// Title-case the profile word so it matches the matched-path label.
|
|
586
|
-
const cap=(s)=> s ? s.charAt(0).toUpperCase()+s.slice(1) : s;
|
|
587
|
-
const label = match ? match.label
|
|
588
|
-
: (healthR.boundProfile ? cap(healthR.boundProfile)+' (user '+uid+')'
|
|
589
|
-
: 'user '+uid);
|
|
590
|
-
lastBoundLabel=label;
|
|
591
|
-
nameEl.textContent=label;
|
|
592
|
-
}catch{ /* transient fetch error: leave whatever is shown */ }
|
|
593
|
-
}
|
|
594
|
-
// live defaults true so the SSE handler (es.onmessage=onEvent) keeps firing alerts on
|
|
595
|
-
// genuine arrivals; backfill() passes live=false so replayed device HISTORY counts toward
|
|
596
|
-
// widgets without triggering alerts for events that already happened.
|
|
597
|
-
function onEvent(m, live){ if(live===undefined) live=true; let f; try{f=JSON.parse(m.data);}catch{return;}
|
|
598
|
-
if(f.type==='_status'){ setConn(f.event.state, f.event.msg); return; }
|
|
599
|
-
if(f.type==='_error'){ setConn('waiting', f.event.msg); return; }
|
|
600
|
-
// Server-replayed log history is tagged replay:true. Treat it as history (live=false)
|
|
601
|
-
// so it never fires alerts or pulses, even on a fresh browser whose own cache is empty
|
|
602
|
-
// (the server log ring outlives/outsizes the client cache, so reconnect replay can carry
|
|
603
|
-
// frames the client never saw). Live frames are unaffected.
|
|
604
|
-
if(f.replay) live=false;
|
|
605
|
-
if(!['network','rtm','clog','log'].includes(f.type)) return;
|
|
606
|
-
lastEventTs=Date.now(); setConn('connected','');
|
|
607
|
-
const ev=f.event||{}; ev._type=f.type;
|
|
608
|
-
// Cache EVERY frame (all types, before the current-tab filter) so a tab switch can
|
|
609
|
-
// replay history — including logs, which the device cannot backfill. Dedup: a frame
|
|
610
|
-
// already cached (re-delivered by a stream reconnect or present in backfill) is not
|
|
611
|
-
// re-counted. cacheFrame() also learns dropdown values via observe().
|
|
612
|
-
const isNew=cacheFrame(f.type,ev);
|
|
613
|
-
if(SPEC.source!=='all' && f.type!==SPEC.source) return; // not for the current tab's widgets
|
|
614
|
-
if(!isNew) return; // already rendered/counted this frame
|
|
615
|
-
if(!matches(ev)) return;
|
|
616
|
-
if(SPEC.bodyContains){ bodyMatches(ev).then(ok=>{ if(ok) acceptEvent(ev,live); }); return; }
|
|
617
|
-
acceptEvent(ev,live);
|
|
618
|
-
}
|
|
619
|
-
async function bodyMatches(ev){ if(ev.id==null || ev._type==='clog') return false; const needle=String(SPEC.bodyContains).toLowerCase();
|
|
620
|
-
try{ const d=await (await fetch('/detail?kind='+(ev._type||SPEC.source)+'&id='+ev.id)).json();
|
|
621
|
-
return ((d.request_body||'')+(d.response_body||'')).toLowerCase().includes(needle); }catch{ return false; } }
|
|
622
|
-
const SEEN={};
|
|
623
|
-
const SUGGEST_FIELDS=['event_type','event_name','tag','status_code','channel','url','package'];
|
|
624
|
-
function observe(ev){ for(const f of SUGGEST_FIELDS){ let v=ev[f]; if(v==null||v==='') continue;
|
|
625
|
-
if(f==='url'){ try{ v=new URL(v).pathname.replace('/api/',''); }catch{} }
|
|
626
|
-
v=String(v); if(v.length>80) continue;
|
|
627
|
-
(SEEN[f]||(SEEN[f]=new Set())).add(v);
|
|
628
|
-
const cap = f==='package' ? Infinity : 500;
|
|
629
|
-
while(SEEN[f].size>cap){ const it=SEEN[f].values().next().value; SEEN[f].delete(it); } } }
|
|
630
|
-
// Session cache: every frame seen this browser session, across ALL tabs, keyed for
|
|
631
|
-
// dedup. Switching tabs replays from here so events (and logs, which the device cannot
|
|
632
|
-
// backfill) persist instead of the stream restarting from zero.
|
|
633
|
-
// EVICTION (two bounds, whichever hits first, so memory is bounded on a long-lived tab):
|
|
634
|
-
// 1. COUNT — at most CACHE_CAP frames (FIFO drop oldest).
|
|
635
|
-
// 2. AGE — frames older than CACHE_TTL_MS are dropped on each insert + on a timer.
|
|
636
|
-
// The dedup Set is kept in lockstep with the array (evicted keys are deleted) so it can
|
|
637
|
-
// never grow unbounded. evictCache() also runs on a 30s timer to age out an idle tab.
|
|
638
|
-
const CACHE=[]; const CACHE_KEYS=new Set(); const CACHE_CAP=8000; const CACHE_TTL_MS=15*60*1000;
|
|
639
|
-
function frameKey(type,ev){ return type+':'+(ev.id!=null?ev.id:(ev.log_ts||'')+'|'+(ev.pid||'')+'|'+(ev.message||ev.event_type||ev.event_name||'')); }
|
|
640
|
-
function dropFront(){ const old=CACHE.shift(); if(old) CACHE_KEYS.delete(frameKey(old.type,old.ev)); }
|
|
641
|
-
function evictCache(){ const cutoff=Date.now()-CACHE_TTL_MS;
|
|
642
|
-
while(CACHE.length && CACHE[0].ts<cutoff) dropFront(); // age bound
|
|
643
|
-
while(CACHE.length>CACHE_CAP) dropFront(); // count bound
|
|
644
|
-
}
|
|
645
|
-
setInterval(evictCache,30000); // age out an idle tab even with no new frames arriving
|
|
646
|
-
// Add a frame to the cache. Returns true if NEW (not a dup re-delivery). Learns
|
|
647
|
-
// dropdown values here so suggestions reflect everything seen, not just rendered.
|
|
648
|
-
function cacheFrame(type,ev){ const k=frameKey(type,ev); if(CACHE_KEYS.has(k)) return false;
|
|
649
|
-
CACHE_KEYS.add(k); CACHE.push({type,ev,ts:ev.timestamp||Date.now()}); observe(ev);
|
|
650
|
-
evictCache();
|
|
651
|
-
return true; }
|
|
652
|
-
// Replay the cache into the freshly-built tab, filtered to the current source, in
|
|
653
|
-
// chronological order. Counters/rate/sparkline/graph all reflect it because acceptEvent
|
|
654
|
-
// buckets by each event's REAL timestamp. Like backfill, this is HISTORY: it passes
|
|
655
|
-
// live=false so alerts do not fire — only genuine /stream arrivals (onEvent default) do.
|
|
656
|
-
// Replay applies matches() but NOT bodyContains: the body filter is live-only by design.
|
|
657
|
-
// Honoring it here would mean one /detail fetch per cached row on every tab switch (a
|
|
658
|
-
// fetch storm up to the full cache). Replay shows what you already saw; the body filter
|
|
659
|
-
// stays a live-only refinement.
|
|
660
|
-
function replayCache(){
|
|
661
|
-
const rows=CACHE.filter(c=> (SPEC.source==='all'||c.type===SPEC.source));
|
|
662
|
-
for(const c of rows){ const ev=c.ev; ev._type=c.type; if(matches(ev)) acceptEvent(ev,false); }
|
|
663
|
-
}
|
|
664
|
-
// Rebuild the FEED rows from the session cache for the current FEED.filter, WITHOUT
|
|
665
|
-
// touching counters/graph (those already counted every event). Needed because addRow now
|
|
666
|
-
// drops non-matching rows from the DOM entirely (not insert-then-hide), so changing or
|
|
667
|
-
// clearing the feed filter cannot just un-hide rows that are no longer there — it must
|
|
668
|
-
// re-add the matching ones from the cache.
|
|
669
|
-
function rebuildFeedFromCache(){
|
|
670
|
-
if(!feed) return;
|
|
671
|
-
feed.innerHTML='';
|
|
672
|
-
const rows=CACHE.filter(c=> (SPEC.source==='all'||c.type===SPEC.source));
|
|
673
|
-
for(const c of rows){ const ev=c.ev; ev._type=c.type;
|
|
674
|
-
if(!matches(ev)) continue;
|
|
675
|
-
const r=addRow(ev, ev.timestamp||Date.now());
|
|
676
|
-
if(r && SPEC.extract) enrich(ev).then(val=>{ if(val!=null&&r) appendExtract(r,val); });
|
|
677
|
-
}
|
|
678
|
-
if(FEED.sort!=='newest') applyFeedControls(); else updateFeedCount();
|
|
679
|
-
}
|
|
680
|
-
// accept an event into the widgets. live=true for stream arrivals (fires alerts, enrich,
|
|
681
|
-
// pulse); live=false for cache replay (silent, time-accurate). Buckets by the event's own
|
|
682
|
-
// timestamp so replayed history lands in the right second of the rate/sparkline/graph.
|
|
683
|
-
function acceptEvent(ev, live){ if(live===undefined) live=true; const now=Date.now(); const ets=ev.timestamp||now;
|
|
684
|
-
const ageSec=Math.max(0,Math.floor((now-ets)/1000));
|
|
685
|
-
total++; times.push(ets);
|
|
686
|
-
const si=perSec.length-1-ageSec; if(si>=0&&si<perSec.length) perSec[si]++;
|
|
687
|
-
if(GRAPH){ const gi=GRAPH.N-1-ageSec; if(gi>=0&&gi<GRAPH.N){ for(const s of GRAPH.series){ const d=s.def;
|
|
688
|
-
if(d.metric==='ratePerSec') s.buckets[gi]++;
|
|
689
|
-
else if(d.metric==='count') s.buckets[gi]++;
|
|
690
|
-
else if(d.metric==='avg'){ const v=Number(ev[d.field]); if(!isNaN(v)){ s.buckets[gi]+=v; s.counts[gi]++; } }
|
|
691
|
-
else if(d.metric==='errorRate'){ s.counts[gi]++; if(Number(ev.status_code)>=400) s.buckets[gi]++; }
|
|
692
|
-
} } }
|
|
693
|
-
if(counterEl){ syncCounter(); if(live){ counterEl.classList.remove('pulse'); void counterEl.offsetWidth; counterEl.classList.add('pulse'); } }
|
|
694
|
-
const row=FEED.paused?null:addRow(ev,ets); if(row && live && SPEC.extract) enrich(ev).then(val=>{ if(val!=null&&row) appendExtract(row,val); });
|
|
695
|
-
if(live){ liveMatches++;
|
|
696
|
-
if(SPEC.alert){ const at=Number(SPEC.alert.at)||0;
|
|
697
|
-
// at:N fires once on the Nth LIVE match (liveMatches), not the Nth event overall —
|
|
698
|
-
// device history replayed at build time must not consume the threshold.
|
|
699
|
-
if(at>0){ if(liveMatches===at) fireAlert(ev, 'reached '+at); }
|
|
700
|
-
else fireAlert(ev);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
let audioCtx=null;
|
|
705
|
-
function beep(){ try{ audioCtx=audioCtx||new (window.AudioContext||window.webkitAudioContext)(); const o=audioCtx.createOscillator(), g=audioCtx.createGain(); o.type='sine'; o.frequency.value=880; o.connect(g); g.connect(audioCtx.destination); g.gain.setValueAtTime(0.0001,audioCtx.currentTime); g.gain.exponentialRampToValueAtTime(0.25,audioCtx.currentTime+0.01); g.gain.exponentialRampToValueAtTime(0.0001,audioCtx.currentTime+0.25); o.start(); o.stop(audioCtx.currentTime+0.26); }catch{} }
|
|
706
|
-
let alertTimer=null;
|
|
707
|
-
function dismissAlert(){ const b=document.getElementById('alertbanner'); b.classList.remove('on'); if(alertTimer){ clearTimeout(alertTimer); alertTimer=null; } }
|
|
708
|
-
function fireAlert(ev, note){ const a=SPEC.alert||{};
|
|
709
|
-
if(a.flash!==false){ const f=document.getElementById('flash'); f.classList.add('on'); setTimeout(()=>f.classList.remove('on'),140); }
|
|
710
|
-
if(a.sound!==false) beep();
|
|
711
|
-
const tail = note ? note : esc(ev.event_type||ev.event_name||shortUrl(ev.url)||'');
|
|
712
|
-
const b=document.getElementById('alertbanner'); b.innerHTML='<span>'+esc(a.say||'ALERT')+' — '+tail+'</span><span class="x">dismiss ✕</span>'; b.classList.add('on');
|
|
713
|
-
if(!alertTimer) alertTimer=setTimeout(dismissAlert,2500);
|
|
714
|
-
}
|
|
715
|
-
function tick(){ const now=Date.now(), w=(SPEC.rateWindowSec||10)*1000; while(times.length&×[0]<now-w) times.shift(); if(rateEl) rateEl.textContent=times.length; perSec.push(0); if(perSec.length>30) perSec.shift(); if(sparkEl){ const mx=Math.max(1,...perSec); sparkEl.innerHTML=perSec.map(n=>'<div class="bar" style="height:'+(2+n/mx*56)+'px"></div>').join(''); }
|
|
716
|
-
if(GRAPH){ drawGraph(); for(const s of GRAPH.series){ s.buckets.push(0); s.buckets.shift(); s.counts.push(0); s.counts.shift(); } }
|
|
717
|
-
if((SPEC.source==='log'||SPEC.source==='all') && document.activeElement!==document.getElementById('fpkg')) refreshPkgOptions();
|
|
718
|
-
}
|
|
719
|
-
const GCOLORS=['#39d98a','#ffd479'];
|
|
720
|
-
function unitFor(def){
|
|
721
|
-
if(def.unit) return def.unit;
|
|
722
|
-
if(def.metric==='ratePerSec') return '/s';
|
|
723
|
-
if(def.metric==='avg') return (def.field&&/ms|duration/i.test(def.field))?'ms':'';
|
|
724
|
-
if(def.metric==='errorRate') return '%';
|
|
725
|
-
if(def.metric==='count') return '';
|
|
726
|
-
return '';
|
|
727
|
-
}
|
|
728
|
-
function fmtNum(v){ if(v==null) return '—'; const a=Math.abs(v); return a>=100?Math.round(v):a>=10?Math.round(v*10)/10:Math.round(v*100)/100; }
|
|
729
|
-
function seriesValues(s){
|
|
730
|
-
if(s.def.metric==='count'){ let run=0; return s.buckets.map(b=>run+=b); }
|
|
731
|
-
if(s.def.metric==='avg') return s.buckets.map((b,i)=> s.counts[i]? b/s.counts[i] : null);
|
|
732
|
-
if(s.def.metric==='errorRate') return s.buckets.map((b,i)=> s.counts[i]? b/s.counts[i] : null);
|
|
733
|
-
return s.buckets.slice();
|
|
734
|
-
}
|
|
735
|
-
function drawGraph(){ const svg=document.getElementById('graph'); if(!svg) return;
|
|
736
|
-
const VB_W=640, VB_H=200, N=GRAPH.N;
|
|
737
|
-
const L=46, R=GRAPH.series.length>1?52:14, T=10, B=26;
|
|
738
|
-
const x0=L, x1=VB_W-R, y0=T, y1=VB_H-B, pw=x1-x0, ph=y1-y0;
|
|
739
|
-
let out='', legend='';
|
|
740
|
-
out+='<rect x="'+x0+'" y="'+y0+'" width="'+pw+'" height="'+ph+'" fill="none" stroke="#1e2a3a"/>';
|
|
741
|
-
for(let k=0;k<=4;k++){ const fx=x0+(k/4)*pw; const secsAgo=Math.round((1-k/4)*N); const lbl=secsAgo===0?'now':'-'+secsAgo+'s';
|
|
742
|
-
out+='<line x1="'+fx.toFixed(1)+'" y1="'+y1+'" x2="'+fx.toFixed(1)+'" y2="'+(y1+4)+'" stroke="#33455c"/>';
|
|
743
|
-
out+='<text x="'+fx.toFixed(1)+'" y="'+(y1+16)+'" fill="#5f7186" font-size="10" text-anchor="middle">'+lbl+'</text>'; }
|
|
744
|
-
GRAPH.series.forEach((s,si)=>{ const raw=seriesValues(s);
|
|
745
|
-
const scale = s.def.metric==='errorRate'?100:1;
|
|
746
|
-
let last=null; const vals=raw.map(v=>{ v=(v==null?null:v*scale); if(v!=null){ last=v; return v; } return last; });
|
|
747
|
-
const real=vals.filter(v=>v!=null); const mx=Math.max(1,...real);
|
|
748
|
-
const col=GCOLORS[si], unit=unitFor(s.def);
|
|
749
|
-
const axX = si===0 ? x0 : x1; const anchor = si===0?'end':'start'; const tx = si===0 ? x0-6 : x1+6;
|
|
750
|
-
for(let k=0;k<=2;k++){ const val=mx*k/2; const fy=y1-(k/2)*ph;
|
|
751
|
-
out+='<text x="'+tx+'" y="'+(fy+3).toFixed(1)+'" fill="'+col+'" font-size="10" text-anchor="'+anchor+'">'+fmtNum(val)+'</text>'; }
|
|
752
|
-
out+='<text x="'+axX+'" y="'+(y0-1)+'" fill="'+col+'" font-size="9" text-anchor="'+(si===0?'start':'end')+'">'+esc(unit||s.def.metric)+'</text>';
|
|
753
|
-
const pts=vals.map((v,i)=>{ if(v==null) return null; const px=x0+(i/(N-1))*pw; const py=y1-(v/mx)*ph; return px.toFixed(1)+','+py.toFixed(1); }).filter(Boolean).join(' ');
|
|
754
|
-
if(pts) out+='<polyline fill="none" stroke="'+col+'" stroke-width="2" points="'+pts+'"/>';
|
|
755
|
-
const h=SPEC.highlight;
|
|
756
|
-
if(h && s.def.field && h.field===s.def.field && h.value<=mx){ const ty=y1-(h.value/mx)*ph;
|
|
757
|
-
out+='<line x1="'+x0+'" y1="'+ty.toFixed(1)+'" x2="'+x1+'" y2="'+ty.toFixed(1)+'" stroke="#ff3b30" stroke-width="1" stroke-dasharray="4 3"/>';
|
|
758
|
-
out+='<text x="'+(x0+4)+'" y="'+(ty-3).toFixed(1)+'" fill="#ff6b60" font-size="9">'+esc(h.label||('>'+h.value))+' '+esc(unit||'')+'</text>'; }
|
|
759
|
-
const shown=fmtNum(last)+(unit?(' '+unit):'');
|
|
760
|
-
legend+='<span style="color:'+col+'">■ '+esc(s.def.label||s.def.metric)+': '+shown+'</span> ';
|
|
761
|
-
});
|
|
762
|
-
svg.innerHTML=out; const lg=document.getElementById('legend'); if(lg) lg.innerHTML=legend+' <span style="color:#5f7186">x: time (last '+N+'s, right=now)</span>';
|
|
763
|
-
}
|
|
764
|
-
function shortUrl(u){ try{ return new URL(u).pathname.replace('/api/',''); }catch{ return u||''; } }
|
|
765
|
-
function shortPkg(p){ if(!p) return ''; const parts=String(p).split('.'); return parts.length<=2?p:parts.slice(-2).join('.'); }
|
|
766
|
-
function addRow(ev,now){ if(!has('feed')) return null; const r=document.createElement('div'); r.className='row'; const ts=new Date(ev.timestamp||now).toLocaleTimeString();
|
|
767
|
-
const typeChip = (SPEC.source==='all' && ev._type) ? '<span class="tchip t-'+ev._type+'">'+ev._type+'</span>' : '';
|
|
768
|
-
if((ev._type||SPEC.source)==='log'){
|
|
769
|
-
const lv=(ev.level||'').toUpperCase();
|
|
770
|
-
const pkg = ev.package ? '<span class="logpkg" title="'+esc(ev.package)+'">'+esc(shortPkg(ev.package))+'</span>' : '';
|
|
771
|
-
r.innerHTML='<span class="t">'+ts+'</span>'+typeChip+'<span class="lvl lvl-'+lv+'">'+esc(lv||'LOG')+'</span>'+pkg+'<span class="logtag">'+esc(ev.tag||'')+'</span><span class="logmsg">'+esc(ev.message||'')+'</span>';
|
|
772
|
-
} else {
|
|
773
|
-
r.innerHTML='<span class="t">'+ts+'</span>'+typeChip+'<span class="dir-'+(ev.direction||'')+'">'+(ev.direction||ev.method||'')+'</span><span>'+esc(ev.event_type||ev.event_name||shortUrl(ev.url))+'</span>'+(ev.channel?'<span class="chan">'+esc(ev.channel)+'</span>':'');
|
|
774
|
-
}
|
|
775
|
-
if(showInspector()){ r.classList.add('clickable'); r._ev=ev; r.onclick=()=>inspect(r,ev); }
|
|
776
|
-
if(breaches(ev)){ r.classList.add('breach'); const h=SPEC.highlight; const v=Number(ev[h.field]);
|
|
777
|
-
const unit = /ms|duration/i.test(h.field)?'ms' : (h.field==='status_code'?'':'');
|
|
778
|
-
const b=document.createElement('span'); b.className='breachbadge'; b.textContent=fmtNum(v)+unit+' '+(h.op||'>')+' '+h.value+unit;
|
|
779
|
-
r.appendChild(b); }
|
|
780
|
-
r._search=(r.textContent||'').toLowerCase(); r._ev=ev; r._ts=ev.timestamp||now;
|
|
781
|
-
// When the feed text filter is active, DROP non-matching rows entirely instead of
|
|
782
|
-
// inserting-then-hiding them. The 200-row DOM cap below counts every child, so hidden
|
|
783
|
-
// non-matching rows would otherwise flood the cap and evict the matching rows the user
|
|
784
|
-
// is watching for (filtered rows flash in, then vanish as unmatched rows push them out).
|
|
785
|
-
if(FEED.filter && !r._search.includes(FEED.filter)) return null;
|
|
786
|
-
const atTop = feed.scrollTop <= 4;
|
|
787
|
-
feed.insertBefore(r,feed.firstChild); while(feed.children.length>200) feed.removeChild(feed.lastChild);
|
|
788
|
-
if(!atTop){ feed.scrollTop += r.offsetHeight; }
|
|
789
|
-
if(FEED.sort!=='newest') applyFeedControls(); else updateFeedCount();
|
|
790
|
-
return r; }
|
|
791
|
-
const LOG_LEVEL_RANK={ FATAL:5, ERROR:4, WARN:3, INFO:2, DEBUG:1, VERBOSE:0 };
|
|
792
|
-
const TYPE_RANK={ network:0, rtm:1, clog:2, log:3 };
|
|
793
|
-
function sortOptionsFor(spec){
|
|
794
|
-
const src=spec.source, opts=[{key:'newest',label:'newest first',get:r=>r._ts,dir:'desc'},{key:'oldest',label:'oldest first',get:r=>r._ts,dir:'asc'}];
|
|
795
|
-
if(src==='all'){
|
|
796
|
-
opts.push({key:'type',label:'group by type',get:r=>TYPE_RANK[r._ev._type]??9,dir:'asc'});
|
|
797
|
-
return opts;
|
|
798
|
-
}
|
|
799
|
-
if(src==='network'){
|
|
800
|
-
opts.push({key:'slow',label:'slowest (response time)',get:r=>Number(r._ev.duration_ms),dir:'desc'});
|
|
801
|
-
opts.push({key:'status',label:'status code (high→low)',get:r=>Number(r._ev.status_code),dir:'desc'});
|
|
802
|
-
}
|
|
803
|
-
if(src==='log') opts.push({key:'severity',label:'severity (errors first)',get:r=>LOG_LEVEL_RANK[(r._ev.level||'').toUpperCase()]??-1,dir:'desc'});
|
|
804
|
-
if(src==='rtm') opts.push({key:'evtype',label:'event type (A→Z)',get:r=>r._ev.event_type||'',dir:'asc'});
|
|
805
|
-
if(src==='clog') opts.push({key:'name',label:'clog name (A→Z)',get:r=>r._ev.event_name||'',dir:'asc'});
|
|
806
|
-
if(spec.highlight && spec.highlight.field && !opts.some(o=>o.key===spec.highlight.field))
|
|
807
|
-
opts.push({key:spec.highlight.field,label:spec.highlight.field+' (high→low)',get:r=>Number(r._ev[spec.highlight.field]),dir:'desc'});
|
|
808
|
-
return opts;
|
|
809
|
-
}
|
|
810
|
-
function breaches(ev){ const h=SPEC.highlight; if(!h) return false; const v=Number(ev[h.field]); if(isNaN(v)) return false;
|
|
811
|
-
const op=h.op||'>'; return op==='>'?v>h.value:op==='>='?v>=h.value:op==='<'?v<h.value:op==='<='?v<=h.value:v===h.value; }
|
|
812
|
-
|
|
813
|
-
// ---- feed controls: instant filter, pause, dynamic sort (operate on rendered rows) ----
|
|
814
|
-
let FEED={ filter:'', sort:'newest', paused:false, opts:[] };
|
|
815
|
-
function setupFeedControls(){
|
|
816
|
-
const opts=sortOptionsFor(SPEC);
|
|
817
|
-
const stickySort = opts.some(o=>o.key===FEED.sort) ? FEED.sort : 'newest';
|
|
818
|
-
FEED={ filter:FEED.filter||'', sort:stickySort, paused:false, opts };
|
|
819
|
-
const bar=document.getElementById('feedbar'); if(!bar) return;
|
|
820
|
-
bar.style.display = has('feed') ? 'flex' : 'none';
|
|
821
|
-
const fi=document.getElementById('ffilter'), fs=document.getElementById('fsort'), fp=document.getElementById('fpause');
|
|
822
|
-
fi.value=FEED.filter; fp.textContent='⏸ live'; fp.classList.remove('paused');
|
|
823
|
-
fs.innerHTML=FEED.opts.map(o=>'<option value="'+o.key+'">'+o.label+'</option>').join('');
|
|
824
|
-
fs.value=FEED.sort;
|
|
825
|
-
const fx=document.getElementById('ffilterclear');
|
|
826
|
-
const syncClear=()=>{ if(fx) fx.style.display = fi.value ? 'block' : 'none'; };
|
|
827
|
-
const clearFilterBox=()=>{ fi.value=''; FEED.filter=''; syncClear(); rebuildFeedFromCache(); fi.focus(); };
|
|
828
|
-
syncClear();
|
|
829
|
-
// Rebuild from cache (not just hide/show) because addRow drops non-matching rows from
|
|
830
|
-
// the DOM, so a filter change must re-add matching rows that are no longer present.
|
|
831
|
-
// Debounced: oninput fires per keystroke, and rebuild re-runs enrich() per matching row
|
|
832
|
-
// on extract dashboards, so an undebounced rebuild-per-keystroke can fan out /detail
|
|
833
|
-
// fetches. Settle ~180ms after the last keystroke before rebuilding.
|
|
834
|
-
let filterTimer=null;
|
|
835
|
-
fi.oninput=()=>{ FEED.filter=fi.value.trim().toLowerCase(); syncClear();
|
|
836
|
-
if(filterTimer) clearTimeout(filterTimer); filterTimer=setTimeout(rebuildFeedFromCache,180); };
|
|
837
|
-
fi.onkeydown=(e)=>{ if(e.key==='Escape') clearFilterBox(); };
|
|
838
|
-
if(fx) fx.onclick=clearFilterBox;
|
|
839
|
-
fs.onchange=()=>{ FEED.sort=fs.value; applyFeedControls(); };
|
|
840
|
-
fp.onclick=()=>{ FEED.paused=!FEED.paused; fp.textContent=FEED.paused?'▶ paused':'⏸ live'; fp.classList.toggle('paused',FEED.paused); };
|
|
841
|
-
setupPkgControl();
|
|
842
|
-
renderChips();
|
|
843
|
-
}
|
|
844
|
-
function renderChips(){
|
|
845
|
-
const wrap=document.getElementById('chips'); if(!wrap) return;
|
|
846
|
-
if(!SPEC||SPEC.blank||!has('feed')){ wrap.style.display='none'; return; }
|
|
847
|
-
const m=(SPEC.match&&typeof SPEC.match==='object')?SPEC.match:{};
|
|
848
|
-
const items=Object.entries(m).map(([k,v])=>({k,v,kind:'match'}));
|
|
849
|
-
if(SPEC.bodyContains) items.push({k:'body contains',v:SPEC.bodyContains,kind:'body'});
|
|
850
|
-
if(!items.length){ wrap.style.display='none'; return; }
|
|
851
|
-
wrap.style.display='flex';
|
|
852
|
-
wrap.innerHTML=items.map((it,i)=>'<span class="chip"><b>'+esc(it.k)+':</b><span class="v">'+esc(String(it.v))+'</span><span class="x" data-i="'+i+'">✕</span></span>').join('');
|
|
853
|
-
wrap.querySelectorAll('.x').forEach(x=>x.onclick=()=>{ const it=items[Number(x.dataset.i)]; clearFilter(it); });
|
|
854
|
-
}
|
|
855
|
-
async function clearFilter(it){
|
|
856
|
-
const spec=JSON.parse(JSON.stringify(SPEC));
|
|
857
|
-
if(it.kind==='body') delete spec.bodyContains;
|
|
858
|
-
else { if(spec.match) delete spec.match[it.k]; if(spec.match&&!Object.keys(spec.match).length) delete spec.match; }
|
|
859
|
-
await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)}).catch(()=>{});
|
|
860
|
-
}
|
|
861
|
-
function setupPkgControl(){
|
|
862
|
-
const wrap=document.getElementById('fpkgwrap'), sel=document.getElementById('fpkg');
|
|
863
|
-
if(!wrap||!sel) return;
|
|
864
|
-
const isLogish = SPEC.source==='log'||SPEC.source==='all';
|
|
865
|
-
wrap.style.display = isLogish ? 'flex' : 'none';
|
|
866
|
-
if(!isLogish) return;
|
|
867
|
-
refreshPkgOptions();
|
|
868
|
-
sel.onchange=async()=>{ const v=sel.value;
|
|
869
|
-
const spec=JSON.parse(JSON.stringify(SPEC)); spec.match=spec.match||{};
|
|
870
|
-
if(v==='__all__') delete spec.match.package; else spec.match.package=v;
|
|
871
|
-
if(!Object.keys(spec.match).length) delete spec.match;
|
|
872
|
-
await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)}).catch(()=>{});
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
// Package options are learned live from the stream (observed in SEEN.package). No internal
|
|
876
|
-
// package list is baked in; the running debug build surfaces as soon as it emits a line.
|
|
877
|
-
function refreshPkgOptions(){
|
|
878
|
-
const sel=document.getElementById('fpkg'); if(!sel) return;
|
|
879
|
-
const cur=(SPEC.match&&SPEC.match.package)||'__all__';
|
|
880
|
-
const observed=[...(SEEN.package||new Set())].sort();
|
|
881
|
-
let pkgs=observed.slice();
|
|
882
|
-
if(cur!=='__all__'&&!pkgs.includes(cur)) pkgs.unshift(cur);
|
|
883
|
-
const opts=['<option value="__all__">all apps</option>'].concat(pkgs.map(p=>'<option value="'+esc(p)+'"'+(p===cur?' selected':'')+'>'+esc(shortPkg(p))+'</option>'));
|
|
884
|
-
sel.innerHTML=opts.join(''); sel.value=cur;
|
|
885
|
-
}
|
|
886
|
-
function applyFeedControls(){ if(!feed) return; const rows=[...feed.children];
|
|
887
|
-
let shown=0; for(const r of rows){ const hide = FEED.filter && !(r._search||'').includes(FEED.filter); r.classList.toggle('hidden',hide); if(!hide) shown++; }
|
|
888
|
-
const opt=FEED.opts.find(o=>o.key===FEED.sort)||FEED.opts[0];
|
|
889
|
-
const val=(r)=>{ const v=opt.get(r); return (v==null||(typeof v==='number'&&isNaN(v)))?null:v; };
|
|
890
|
-
rows.sort((a,b)=>{ const va=val(a), vb=val(b); if(va==null&&vb==null) return 0; if(va==null) return 1; if(vb==null) return -1;
|
|
891
|
-
if(va<vb) return opt.dir==='asc'?-1:1; if(va>vb) return opt.dir==='asc'?1:-1; return 0; });
|
|
892
|
-
for(const r of rows) feed.appendChild(r);
|
|
893
|
-
updateFeedCount(shown);
|
|
894
|
-
}
|
|
895
|
-
function updateFeedCount(shown){ const c=document.getElementById('fcount'); if(!c||!feed) return;
|
|
896
|
-
const total=feed.children.length; if(shown==null) shown=[...feed.children].filter(r=>!r.classList.contains('hidden')).length;
|
|
897
|
-
c.textContent = FEED.filter ? (shown+' / '+total+' shown') : (total+' rows');
|
|
898
|
-
syncCounter(shown); }
|
|
899
|
-
function syncCounter(shown){
|
|
900
|
-
if(!counterEl) return;
|
|
901
|
-
const label=document.getElementById('counterLabel');
|
|
902
|
-
if(FEED && FEED.filter){
|
|
903
|
-
if(shown==null) shown=[...feed.children].filter(r=>!r.classList.contains('hidden')).length;
|
|
904
|
-
counterEl.textContent=shown;
|
|
905
|
-
if(label) label.textContent = total>feed.children.length ? 'matching "'+FEED.filter+'" (recent)' : 'matching "'+FEED.filter+'"';
|
|
906
|
-
} else {
|
|
907
|
-
counterEl.textContent=total;
|
|
908
|
-
if(label) label.textContent='total matched';
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
let inspectToken=0;
|
|
912
|
-
async function inspect(row,ev){
|
|
913
|
-
const insp=document.getElementById('inspector');
|
|
914
|
-
if(row.classList.contains('sel')){ row.classList.remove('sel'); insp.innerHTML='<div class="label">inspector</div><div class="empty">click a row to see its full detail</div>'; inspectToken++; return; }
|
|
915
|
-
document.querySelectorAll('.row.sel').forEach(x=>x.classList.remove('sel')); row.classList.add('sel');
|
|
916
|
-
const myToken=++inspectToken;
|
|
917
|
-
insp.innerHTML='<div class="label">inspector</div><div class="empty">loading…</div>';
|
|
918
|
-
let detail=ev;
|
|
919
|
-
const kind=ev._type||SPEC.source;
|
|
920
|
-
if(ev.id!=null && kind!=='clog' && kind!=='log'){ try{ detail=await (await fetch('/detail?kind='+kind+'&id='+ev.id)).json(); }catch{} }
|
|
921
|
-
if(myToken!==inspectToken) return;
|
|
922
|
-
const shown=JSON.parse(JSON.stringify(detail));
|
|
923
|
-
for(const k of ['response_body','request_body','data']){ if(typeof shown[k]==='string'){ try{ shown[k]=JSON.parse(shown[k]); }catch{} } }
|
|
924
|
-
INSPECTED=detail; // raw detail for the copy actions
|
|
925
|
-
// Copy actions: JSON always; Body + cURL when this is a network call (has url/method).
|
|
926
|
-
const isNet=(kind==='network')||detail.url!=null||detail.method!=null;
|
|
927
|
-
let btns='<button class="copybtn" data-c="json">Copy JSON</button>';
|
|
928
|
-
if(isNet) btns+='<button class="copybtn" data-c="body">Copy body</button><button class="copybtn" data-c="curl">Copy cURL</button>';
|
|
929
|
-
insp.innerHTML='<div class="label">inspector — '+esc(ev.event_name||ev.event_type||shortUrl(ev.url)||'detail')+'</div><div class="copybar">'+btns+'</div><pre>'+esc(JSON.stringify(shown,null,2))+'</pre>';
|
|
930
|
-
insp.querySelectorAll('.copybtn').forEach(b=>b.onclick=()=>copyInspected(b.dataset.c,b));
|
|
931
|
-
}
|
|
932
|
-
let INSPECTED=null;
|
|
933
|
-
function copyInspected(kind,btn){ const d=INSPECTED||{}; let text='';
|
|
934
|
-
if(kind==='json') text=JSON.stringify(d,null,2);
|
|
935
|
-
else if(kind==='body') text=String(d.response_body??d.request_body??'');
|
|
936
|
-
else if(kind==='curl') text=toCurl(d);
|
|
937
|
-
navigator.clipboard?.writeText(text).then(()=>{ const o=btn.textContent; btn.textContent='copied ✓'; setTimeout(()=>btn.textContent=o,1200); }).catch(()=>{});
|
|
938
|
-
}
|
|
939
|
-
// Build a runnable curl from a captured network call. Headers + method + body if present.
|
|
940
|
-
function toCurl(d){ if(!d.url) return ''; const parts=["curl -i"];
|
|
941
|
-
if(d.method && d.method!=='GET') parts.push("-X "+d.method);
|
|
942
|
-
const h=d.request_headers||d.requestHeaders||{};
|
|
943
|
-
for(const k of Object.keys(h)) parts.push("-H "+shq(k+": "+h[k]));
|
|
944
|
-
const body=d.request_body||d.requestBody; if(body) parts.push("--data "+shq(typeof body==='string'?body:JSON.stringify(body)));
|
|
945
|
-
parts.push(shq(d.url));
|
|
946
|
-
return parts.join(" \\\n ");
|
|
947
|
-
}
|
|
948
|
-
function shq(s){ return "'"+String(s).replace(/'/g,"'\\''")+"'"; }
|
|
949
|
-
function appendExtract(row,val){ const s=document.createElement('span'); s.className='extract'; s.textContent=(SPEC.extract.label?SPEC.extract.label+': ':'')+val; row.appendChild(s); }
|
|
950
|
-
// Escapes the five HTML-significant chars. Quotes are included because esc() output is
|
|
951
|
-
// interpolated into double-quoted ATTRIBUTES (title="...", value="..."), not just text
|
|
952
|
-
// content — a spec-authored title/value containing a quote would otherwise break out of the
|
|
953
|
-
// attribute and inject an event handler (DOM-XSS with no < or > needed). All esc() call
|
|
954
|
-
// sites in this file feed HTML, so escaping quotes is safe everywhere.
|
|
955
|
-
function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
956
|
-
// Human-readable one-line summary of a spec's actual filters + constraints, so every tab
|
|
957
|
-
// (the Custom one especially) explains what it is showing rather than just "source=X".
|
|
958
|
-
// Covers the fields the old "source=X match={}" line dropped: highlight, bodyContains,
|
|
959
|
-
// alert, graph series, rate window. Returns a "·"-joined clause string.
|
|
960
|
-
function describeConstraints(spec){
|
|
961
|
-
const parts=['source '+spec.source];
|
|
962
|
-
if(spec.match && Object.keys(spec.match).length)
|
|
963
|
-
parts.push('where '+Object.entries(spec.match).map(([k,v])=>k+'='+v).join(', '));
|
|
964
|
-
if(spec.bodyContains) parts.push('body contains "'+spec.bodyContains+'"');
|
|
965
|
-
if(spec.highlight && spec.highlight.field)
|
|
966
|
-
parts.push('flag '+spec.highlight.field+' '+(spec.highlight.op||'>')+' '+spec.highlight.value+(spec.highlight.label?' ('+spec.highlight.label+')':''));
|
|
967
|
-
if(Array.isArray(spec.series) && spec.series.length)
|
|
968
|
-
parts.push('graph '+spec.series.map(s=>s.label||s.metric+(s.field?' of '+s.field:'')).join(' vs '));
|
|
969
|
-
if(spec.extract && spec.extract.field) parts.push('extract '+spec.extract.field);
|
|
970
|
-
if(spec.alert){ const a=spec.alert; parts.push('alert'+(a.at?' at '+a.at:'')+(a.say?' "'+a.say+'"':'')); }
|
|
971
|
-
if(spec.rateWindowSec) parts.push('rate window '+spec.rateWindowSec+'s');
|
|
972
|
-
return parts.join(' · ');
|
|
973
|
-
}
|
|
974
|
-
function card(label,inner){ const d=document.createElement('div'); d.className='card'; d.innerHTML='<div class="label">'+label+'</div>'+inner; return d; }
|
|
975
|
-
function big(v,id,unit){ return '<div class="big"><span id="'+id+'">'+v+'</span>'+(unit?'<span class="unit">'+unit+'</span>':'')+'</div>'; }
|
|
976
|
-
|
|
977
|
-
// ---- DB browser view (source:"db"): db list -> tables -> read-only query grid ----
|
|
978
|
-
let DBV={ db:null, user:null };
|
|
979
|
-
// Build the ?user= suffix so every db call targets the profile the operator picked
|
|
980
|
-
// (or the dashboard's bound profile). Keeps list / pull / query on the same store.
|
|
981
|
-
function dbUserQS(){ return DBV.user!=null ? '&user='+encodeURIComponent(DBV.user) : ''; }
|
|
982
|
-
async function buildDbView(spec){
|
|
983
|
-
const dbsEl=document.getElementById('db_dbs'), tblsEl=document.getElementById('db_tbls');
|
|
984
|
-
document.getElementById('db_run').onclick=dbRun;
|
|
985
|
-
document.getElementById('db_sql').onkeydown=e=>{ if(e.key==='Enter') dbRun(); };
|
|
986
|
-
tblsEl.innerHTML='<div class="msg">pick a database</div>';
|
|
987
|
-
document.getElementById('db_out').innerHTML='<div class="msg">pick a database, then a table</div>';
|
|
988
|
-
document.getElementById('db_meta').textContent='';
|
|
989
|
-
dbsEl.innerHTML='<div class="msg">loading…</div>';
|
|
990
|
-
await loadDbList(spec);
|
|
991
|
-
}
|
|
992
|
-
function profileLabel(u){ return u===''||u==null ? 'device' : (u==='0' ? 'personal (user 0)' : 'secondary (user '+esc(u)+')'); }
|
|
993
|
-
async function loadDbList(spec){
|
|
994
|
-
const dbsEl=document.getElementById('db_dbs');
|
|
995
|
-
try{ const r=await (await fetch('/dbs?'+dbUserQS().slice(1))).json();
|
|
996
|
-
if(r.error){ dbsEl.innerHTML='<div class="msg err">'+esc(r.error)+'</div>'; return; }
|
|
997
|
-
if(!r.names||!r.names.length){ dbsEl.innerHTML='<div class="msg">no databases (device unreachable, or debug build not installed)</div>'; return; }
|
|
998
|
-
DBV.user = r.user;
|
|
999
|
-
// Which profile's store this is. With more than one profile, show ONLY a switcher
|
|
1000
|
-
// (the dropdown's selected option already names the profile — no redundant text line).
|
|
1001
|
-
// With a single profile, show it as plain text.
|
|
1002
|
-
const avail=r.availableUsers||[];
|
|
1003
|
-
let head;
|
|
1004
|
-
if(avail.length>1){
|
|
1005
|
-
head='<div class="dbprofile"><span class="dbprofcap">profile</span> <select id="db_userpick" title="which profile\'s on-device store to browse">'+
|
|
1006
|
-
avail.map(u=>'<option value="'+esc(u)+'"'+(u===r.user?' selected':'')+'>'+profileLabel(u)+'</option>').join('')+'</select></div>';
|
|
1007
|
-
} else {
|
|
1008
|
-
head='<div class="dbprofile">profile: <b>'+profileLabel(r.user)+'</b></div>';
|
|
1009
|
-
}
|
|
1010
|
-
dbsEl.innerHTML=head+r.names.map(n=>'<div class="dbitem" data-n="'+esc(n)+'">'+esc(n)+'</div>').join('');
|
|
1011
|
-
dbsEl.querySelectorAll('.dbitem').forEach(d=>d.onclick=()=>dbSelectDb(d.dataset.n,d));
|
|
1012
|
-
const pick=document.getElementById('db_userpick');
|
|
1013
|
-
if(pick) pick.onchange=()=>{ DBV.user=pick.value; DBV.db=null;
|
|
1014
|
-
document.getElementById('db_tbls').innerHTML='<div class="msg">pick a database</div>';
|
|
1015
|
-
document.getElementById('db_out').innerHTML='<div class="msg">pick a database, then a table</div>';
|
|
1016
|
-
document.getElementById('db_meta').textContent=''; loadDbList(spec); };
|
|
1017
|
-
if(spec&&spec.db){ const el=[...dbsEl.querySelectorAll('.dbitem')].find(x=>x.dataset.n===spec.db); if(el) dbSelectDb(spec.db,el,spec.table,spec.sql); }
|
|
1018
|
-
}catch(e){ dbsEl.innerHTML='<div class="msg err">'+esc(String(e))+'</div>'; }
|
|
1019
|
-
}
|
|
1020
|
-
async function dbSelectDb(name,el,wantTable,wantSql,refresh){ DBV.db=name;
|
|
1021
|
-
document.querySelectorAll('#db_dbs .dbitem').forEach(x=>x.classList.remove('sel')); if(el) el.classList.add('sel');
|
|
1022
|
-
// clear stale SQL/results so a leftover table query can't run against the newly-picked db
|
|
1023
|
-
document.getElementById('db_sql').value=''; document.getElementById('db_out').innerHTML='<div class="msg">pick a table or write a query</div>';
|
|
1024
|
-
const tblsEl=document.getElementById('db_tbls'); tblsEl.innerHTML='<div class="msg">'+(refresh?'re-pulling':'pulling')+' + reading…</div>';
|
|
1025
|
-
try{ const r=await (await fetch('/dbtables?db='+encodeURIComponent(name)+dbUserQS()+(refresh?'&refresh=1':''))).json();
|
|
1026
|
-
if(r.error){ tblsEl.innerHTML='<div class="msg err">'+esc(r.error)+'</div>'; return; }
|
|
1027
|
-
tblsEl.innerHTML=r.tables.map(t=>'<div class="dbitem" data-t="'+esc(t.name)+'">'+esc(t.name)+'<span class="rows">'+esc(String(t.rows))+'</span></div>').join('');
|
|
1028
|
-
tblsEl.querySelectorAll('.dbitem').forEach(d=>d.onclick=()=>dbSelectTbl(d.dataset.t,d));
|
|
1029
|
-
document.getElementById('db_meta').innerHTML=esc(name)+' — '+((r.sizeBytes/1024)|0)+' KB snapshot, '+r.tables.length+' tables · '+esc(profileLabel(DBV.user))+' <button class="dbrefresh" id="db_refresh" title="re-pull the latest from the device">↻ refresh</button>';
|
|
1030
|
-
const rb=document.getElementById('db_refresh'); if(rb) rb.onclick=()=>dbSelectDb(name,el,null,null,true);
|
|
1031
|
-
if(wantSql){ document.getElementById('db_sql').value=wantSql; dbRun(); }
|
|
1032
|
-
else if(wantTable){ const te=[...tblsEl.querySelectorAll('.dbitem')].find(x=>x.dataset.t===wantTable); if(te) dbSelectTbl(wantTable,te); }
|
|
1033
|
-
}catch(e){ tblsEl.innerHTML='<div class="msg err">'+esc(String(e))+'</div>'; }
|
|
1034
|
-
}
|
|
1035
|
-
function dbSelectTbl(t,el){ document.querySelectorAll('#db_tbls .dbitem').forEach(x=>x.classList.remove('sel')); if(el) el.classList.add('sel');
|
|
1036
|
-
document.getElementById('db_sql').value='SELECT * FROM "'+t+'" LIMIT 100'; dbRun(); }
|
|
1037
|
-
async function dbRun(){ if(!DBV.db) return; const sql=document.getElementById('db_sql').value;
|
|
1038
|
-
const out=document.getElementById('db_out'); out.innerHTML='<div class="msg">running…</div>';
|
|
1039
|
-
try{ const r=await (await fetch('/dbquery?db='+encodeURIComponent(DBV.db)+'&sql='+encodeURIComponent(sql)+dbUserQS())).json();
|
|
1040
|
-
if(r.error){ out.innerHTML='<div class="msg err">'+esc(r.error)+'</div>'; return; }
|
|
1041
|
-
if(!r.rows||!r.rows.length){ out.innerHTML='<div class="msg">0 rows</div>'; document.getElementById('db_meta').textContent=DBV.db+' — 0 rows'; return; }
|
|
1042
|
-
const cols=Object.keys(r.rows[0]);
|
|
1043
|
-
let h='<table><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
|
|
1044
|
-
for(const row of r.rows){ h+='<tr>'+cols.map(c=>'<td title="'+esc(String(row[c]??''))+'">'+esc(String(row[c]??''))+'</td>').join('')+'</tr>'; }
|
|
1045
|
-
h+='</tbody></table>'; out.innerHTML=h;
|
|
1046
|
-
document.getElementById('db_meta').textContent=DBV.db+' — '+r.rows.length+' rows';
|
|
1047
|
-
}catch(e){ out.innerHTML='<div class="msg err">'+esc(String(e))+'</div>'; }
|
|
1048
|
-
}
|
|
1049
|
-
// ---- Screen overlay: a global panel that mirrors the live device screen. -----
|
|
1050
|
-
// OFF by default. The live screen shows real DMs and message content, so the panel
|
|
1051
|
-
// opens to a consent gate first; only after the user accepts does it request frames.
|
|
1052
|
-
// Consent is granted server-side (per process) and the stream/record routes enforce it.
|
|
1053
|
-
const SCREEN={ on:false, consent:false, recording:false, caps:null };
|
|
1054
|
-
function scCopy(text,btn){ navigator.clipboard?.writeText(text).then(()=>{ if(btn){ const t=btn.textContent; btn.textContent='copied'; setTimeout(()=>btn.textContent=t,1200); } }).catch(()=>{}); }
|
|
1055
|
-
// refresh=1 re-probes ffmpeg/scrcpy on the server, so reopening this panel after
|
|
1056
|
-
// installing ffmpeg detects it without restarting the dashboard.
|
|
1057
|
-
async function scLoadCaps(){ try{ SCREEN.caps=await (await fetch('/screen/caps?refresh=1')).json(); SCREEN.consent=!!SCREEN.caps.consent; }catch{ SCREEN.caps=null; } }
|
|
1058
|
-
function scRenderConsent(){
|
|
1059
|
-
const body=document.getElementById('screenbody'); const hint=document.getElementById('schint');
|
|
1060
|
-
hint.textContent='';
|
|
1061
|
-
body.innerHTML='<div class="scconsent">This mirrors the <b>live device screen</b>. It shows whatever is on the phone right now, including open DMs, message content, and notifications. Review what is on screen before sharing it. Frames are not requested until you accept, and the mirror stays on until you restart the dashboard. If you <b>record</b>, the clip is saved to a file on this computer (owner-only, removed when the dashboard restarts).<button class="scaccept" id="scaccept">Show the screen</button></div>';
|
|
1062
|
-
document.getElementById('scaccept').onclick=async()=>{ try{ await fetch('/screen/consent',{method:'POST'}); SCREEN.consent=true; scRenderLive(); }catch{ hint.textContent='consent failed'; } };
|
|
1063
|
-
}
|
|
1064
|
-
function scRenderLive(){
|
|
1065
|
-
const body=document.getElementById('screenbody'); const hint=document.getElementById('schint');
|
|
1066
|
-
const caps=SCREEN.caps||{};
|
|
1067
|
-
if(caps.liveCast){
|
|
1068
|
-
body.innerHTML='<img id="screenimg" alt="device screen"/>';
|
|
1069
|
-
document.getElementById('screenimg').src='/screen/stream?fps=20&t='+Date.now();
|
|
1070
|
-
hint.textContent='capturing first frame (~3s on a foldable)…';
|
|
1071
|
-
setTimeout(()=>{ if(SCREEN.on) hint.textContent='live. ▣ Screen again to close.'; },3500);
|
|
1072
|
-
} else {
|
|
1073
|
-
// No ffmpeg: live cast is unavailable. Screenshot still works (pure screencap).
|
|
1074
|
-
// Only render a copyable command when the server returned a real platform one.
|
|
1075
|
-
// The fallback used to show the literal string "install ffmpeg" with a copy
|
|
1076
|
-
// button, which is not a runnable command and failed when pasted into a terminal.
|
|
1077
|
-
const cmd=caps.installHint;
|
|
1078
|
-
let html='<div class="scinstall">Live mirroring needs <b>ffmpeg</b>, which is not installed. ';
|
|
1079
|
-
if(cmd){
|
|
1080
|
-
html+='Install it with this command, then reopen this panel:<br><br><code>'+esc(cmd)+'</code><button class="sccopy" id="scffcopy">copy</button>';
|
|
1081
|
-
} else {
|
|
1082
|
-
html+='Install it with your system package manager (for example <code>brew install ffmpeg</code> on macOS, <code>winget install ffmpeg</code> on Windows, or <code>sudo apt install ffmpeg</code> on Debian/Ubuntu), then reopen this panel.';
|
|
1083
|
-
}
|
|
1084
|
-
html+='</div>';
|
|
1085
|
-
html+='<div class="scinstall">📷 Screenshot works without ffmpeg — use the button above.</div>';
|
|
1086
|
-
if(caps.scrcpy) html+='<div class="scinstall">scrcpy is installed; for a native mirror window run <code>scrcpy</code> in a terminal.</div>';
|
|
1087
|
-
body.innerHTML=html;
|
|
1088
|
-
const c=document.getElementById('scffcopy'); if(c) c.onclick=()=>scCopy(cmd,c);
|
|
1089
|
-
hint.textContent='live mirroring unavailable (no ffmpeg); screenshot still works.';
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
async function scOpen(){
|
|
1093
|
-
SCREEN.on=true; document.getElementById('screenpanel').classList.add('on');
|
|
1094
|
-
document.body.classList.add('screenopen'); document.getElementById('screenbtn').classList.add('on');
|
|
1095
|
-
await scLoadCaps();
|
|
1096
|
-
if(SCREEN.consent) scRenderLive(); else scRenderConsent();
|
|
1097
|
-
}
|
|
1098
|
-
function scClose(){
|
|
1099
|
-
SCREEN.on=false; document.getElementById('screenpanel').classList.remove('on');
|
|
1100
|
-
document.body.classList.remove('screenopen'); document.getElementById('screenbtn').classList.remove('on');
|
|
1101
|
-
const img=document.getElementById('screenimg'); if(img) img.src=''; // drop the stream viewer
|
|
1102
|
-
if(SCREEN.recording){ fetch('/screen/record/stop',{method:'POST'}).catch(()=>{}); SCREEN.recording=false; document.getElementById('screenrec').textContent='● rec'; }
|
|
1103
|
-
}
|
|
1104
|
-
document.getElementById('screenbtn').onclick=()=>{ SCREEN.on?scClose():scOpen(); };
|
|
1105
|
-
document.getElementById('screenclose').onclick=scClose;
|
|
1106
|
-
document.getElementById('screenshot').onclick=async()=>{
|
|
1107
|
-
if(!SCREEN.consent){ document.getElementById('schint').textContent='accept the notice first'; return; }
|
|
1108
|
-
// Blob download (not a bare <a href> navigation), so taking a screenshot cannot cancel
|
|
1109
|
-
// the in-flight /screen/stream connection and drop the live <img>.
|
|
1110
|
-
try{ const blob=await (await fetch('/screen/shot?t='+Date.now())).blob(); const u=URL.createObjectURL(blob);
|
|
1111
|
-
const a=document.createElement('a'); a.href=u; a.download='radar-screen-'+Date.now()+'.png'; document.body.appendChild(a); a.click(); a.remove();
|
|
1112
|
-
setTimeout(()=>URL.revokeObjectURL(u),10000);
|
|
1113
|
-
}catch{ document.getElementById('schint').textContent='screenshot failed'; }
|
|
1114
|
-
};
|
|
1115
|
-
document.getElementById('screenrec').onclick=async()=>{
|
|
1116
|
-
if(!SCREEN.consent || !(SCREEN.caps&&SCREEN.caps.liveCast)){ document.getElementById('schint').textContent='recording needs ffmpeg + consent'; return; }
|
|
1117
|
-
const rec=document.getElementById('screenrec');
|
|
1118
|
-
if(!SCREEN.recording){ SCREEN.recording=true; rec.textContent='■ stop'; rec.classList.add('rec'); await fetch('/screen/record/start',{method:'POST'}).catch(()=>{}); }
|
|
1119
|
-
else { rec.textContent='saving…'; const r=await (await fetch('/screen/record/stop',{method:'POST'})).json().catch(()=>({})); SCREEN.recording=false; rec.classList.remove('rec'); rec.textContent='● rec';
|
|
1120
|
-
if(r.download){
|
|
1121
|
-
// Fetch the mp4 as a blob and download via an object URL. A bare <a href=/screen/download>
|
|
1122
|
-
// click is a same-origin navigation that the browser can use to cancel the in-flight
|
|
1123
|
-
// /screen/stream connection, which silently kills the live <img> (server keeps casting).
|
|
1124
|
-
// A blob download touches no navigation and leaves the stream connection alone.
|
|
1125
|
-
try{ const blob=await (await fetch(r.download)).blob(); const u=URL.createObjectURL(blob);
|
|
1126
|
-
const a=document.createElement('a'); a.href=u; a.download=(r.file||'radar-screen.mp4'); document.body.appendChild(a); a.click(); a.remove();
|
|
1127
|
-
setTimeout(()=>URL.revokeObjectURL(u),10000);
|
|
1128
|
-
}catch{ document.getElementById('schint').textContent='recording saved on host: '+(r.file||''); }
|
|
1129
|
-
// Belt-and-suspenders: re-arm the live <img> in case the browser still dropped it.
|
|
1130
|
-
const img=document.getElementById('screenimg'); if(img) img.src='/screen/stream?fps=20&t='+Date.now();
|
|
1131
|
-
} }
|
|
1132
|
-
};
|
|
1133
|
-
document.getElementById('screenredetect').onclick=async()=>{
|
|
1134
|
-
document.getElementById('schint').textContent='re-detecting active panel…';
|
|
1135
|
-
const r=await (await fetch('/screen/redetect')).json().catch(()=>({}));
|
|
1136
|
-
if(SCREEN.on && SCREEN.consent && SCREEN.caps&&SCREEN.caps.liveCast){ const img=document.getElementById('screenimg'); if(img) img.src='/screen/stream?fps=20&t='+Date.now(); }
|
|
1137
|
-
document.getElementById('schint').textContent='display: '+(r.display||'default');
|
|
1138
|
-
};
|
|
1139
|
-
|
|
1140
|
-
pollSpec(); // initial build
|
|
1141
|
-
</script></body></html>
|
|
255
|
+
<div id="app"></div>
|
|
256
|
+
<script type="module" src="./next/app.js"></script>
|
|
257
|
+
</body></html>
|