summaryrefslogtreecommitdiff
path: root/modules/ext/ui-cockpit.js
blob: 73fbe9f8d2e5ce4caba578e344485b9a4ccb4013 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// UI mode: cockpit (level 6). Eight live panes straight from /sys/ + real commands.
class UICockpitMode {
    constructor() { this.commands = {}; this.terminal = null; }
    requirements() { return { met: true, missing: [] }; }
    getHelpInfo() { return {}; }
    registerCommands() {}
    init(terminal) {
        this.terminal = terminal;
        if (!terminal.uiShell) return;
        terminal.uiShell.registerMode({
            id: 'cockpit', label: 'Cockpit', level: 6, icon: '🛰',
            tagline: 'Live system telemetry.',
            mount: (api) => this.mount(api)
        });
    }
    mount(api) {
        api.injectCSS('cockpit', `
body[data-ui-mode="cockpit"] { font-size: 13px; }
body[data-ui-mode="cockpit"] #ui-root { display: grid; grid-template-rows: 1fr 190px; min-height: 0; }
#ck-panes { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px;
  background: var(--border-color); min-height: 0; overflow-y: auto; }
#ck-panes .pane { background: var(--bg-color); display: flex; flex-direction: column;
  min-height: 130px; min-width: 0; transition: outline-color .2s; }
#ck-panes .pane h5 { margin: 0; padding: 5px 8px; font-size: .72em; letter-spacing: .1em;
  text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border-color);
  display: flex; gap: 8px; align-items: center; }
#ck-panes .pane h5 .n { margin-left: auto; color: var(--accent-color); }
#ck-panes pre { flex: 1 1 auto; margin: 0; padding: 6px 8px; overflow: auto; white-space: pre-wrap;
  word-break: break-word; font-family: var(--font-primary); font-size: .72em; line-height: 1.35; }
#ck-term { display: flex; flex-direction: column; min-height: 0; border-top: 1px solid var(--border-color); }
#ck-term-bar { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 4px 8px;
  background: var(--bg-secondary); font-size: .78em; }
#ck-term-bar .title { flex: 1 1 auto; color: var(--text-muted); }
#ck-out { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 4px 8px; font-size: .82em; }
#ck-in { flex: 0 0 auto; padding: 4px 8px 8px; }
#ck-in #input-area { display: flex; gap: 6px; align-items: flex-end; }
#ck-in .prompt-label, #ck-in #autocomplete-dropdown { display: none; }
#ck-in #command-input { width: 100%; font-family: var(--font-primary); font-size: .85em; }
@media (max-width: 900px) {
  body[data-ui-mode="cockpit"] #ui-root { grid-template-rows: 1fr 44vh; }
  #ck-panes { grid-template-columns: 1fr 1fr; }
}
`);

        const root = api.root;
        root.innerHTML = '';
        const panes = api.el('div', { id: 'ck-panes' });
        const termWrap = api.el('div', { id: 'ck-term' });
        root.appendChild(panes); root.appendChild(termWrap);

        const defs = [
            { id: 'heap', label: 'heap', cmd: 'cat /sys/heap/stats' },
            { id: 'breakdown', label: 'breakdown', cmd: 'cat /sys/heap/breakdown' },
            { id: 'vfs', label: 'vfs', cmd: 'cat /sys/vfs/stats' },
            { id: 'tools', label: 'tool calls', cmd: 'cat /sys/logs/tool-calls' },
            { id: 'errors', label: 'errors', cmd: 'cat /sys/logs/errors' },
            { id: 'dom', label: 'dom', cmd: 'cat /sys/dom/node-count' },
            { id: 'agents', label: 'agents', cmd: 'agent-list' },
            { id: 'leases', label: 'leases', cmd: 'lease-list' }
        ];
        const slots = {};
        defs.forEach((d, i) => {
            const pane = api.el('div', { class: 'pane' });
            pane.dataset.pane = String(i + 1);
            pane.tabIndex = -1;                 // so F1-F8 can actually move focus, not just scroll
            const h = api.el('h5');
            h.appendChild(api.el('span', { text: 'F' + (i + 1) + ' ' + d.label }));
            h.appendChild(api.el('span', { class: 'n' }));
            const pre = api.el('pre', { text: '…' });
            pane.appendChild(h); pane.appendChild(pre);
            slots[d.id] = { pre, n: h.querySelector('.n') };
            panes.appendChild(pane);
        });

        const bar = api.el('div', { id: 'ck-term-bar' });
        bar.appendChild(api.el('span', { class: 'title', text: 'terminal · F1-F8 focus · F5 refresh' }));
        const styleBtn = api.el('button', { class: 'wx11-btn', text: '⇄' });
        styleBtn.addEventListener('click', () => api.exec('ui picker'));
        bar.appendChild(styleBtn);
        const out = api.el('div', { id: 'ck-out' });
        const inp = api.el('div', { id: 'ck-in' });
        termWrap.appendChild(bar); termWrap.appendChild(out); termWrap.appendChild(inp);
        api.attach(out, api.stock.output);
        api.attach(inp, api.stock.input);

        // Generation guard: eight async captures per tick at 5s — a tick can outlive teardown.
        let gen = 0;
        const refresh = async () => {
            // Same as workspace: bumping per tick cancelled a slow run before the later panes were
            // written, leaving them at "…" indefinitely.
            const my = gen;
            for (const d of defs) {
                try {
                    const text = await api.capture(d.cmd);
                    if (my !== gen) return;
                    const lines = (text || '').trim() ? String(text).trim().split('\n') : ['(empty)'];
                    slots[d.id].pre.textContent = lines.join('\n');
                    slots[d.id].n.textContent = lines.length + 'L';
                } catch (e) {
                    if (my !== gen) return;
                    slots[d.id].pre.textContent = 'unavailable: ' + e.message;
                    slots[d.id].n.textContent = 'ERR';
                }
            }
        };
        refresh();
        const timer = api.interval(refresh, 5000);

        const onKey = (ev) => {
            const m = /^F([1-8])$/.exec(String(ev.key));
            if (!m) return;
            const t = ev.target;
            // Never swallow keys aimed at a text field, and leave F1/F12 to the browser: the handler
            // used to preventDefault on all eight, so help, the address bar and devtools were dead
            // while the cockpit was open (found by review).
            if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
            if (ev.key === 'F1' || ev.key === 'F12') return;
            ev.preventDefault();
            const idx = parseInt(m[1], 10);
            if (idx === 5) { refresh(); return; }              // F5 = refresh, as labelled
            const pane = panes.querySelector('[data-pane="' + idx + '"]');
            if (!pane) return;
            if (pane.focus) pane.focus();
            pane.scrollIntoView({ block: 'nearest' });
            pane.style.outline = '1px solid var(--accent-color)';
            setTimeout(() => { pane.style.outline = ''; }, 900);
        };
        document.addEventListener('keydown', onKey);

        return () => { gen++; document.removeEventListener('keydown', onKey); api.clearInterval(timer); };
    }
}