clusev/resources/js/app.js

691 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
import './webauthn'; // exposes window.clusevWebauthn (register/login ceremonies)
window.Pusher = Pusher;
// Reverb (Pusher protocol). The endpoint is injected at RUNTIME by the app layout
// (window.__clusev.reverb), derived from the effective panel domain — so changing the
// domain from the dashboard needs no JS rebuild. The prod build deliberately bakes NO
// VITE_REVERB_* env (it would freeze the build-time domain); those env reads are only a
// dev/HMR convenience.
//
// CRITICAL: this MUST NOT throw at module top level. Auth pages (login / forced password
// change) use a layout that injects no __clusev, and the prod bundle has no VITE_REVERB_*
// fallback, so the config is legitimately absent there. A thrown `new Echo({key: undefined})`
// would abort this whole module BEFORE the alpine:init block below registers cmdk /
// modalTrigger / dualChart — leaving the command-palette's `x-data="cmdk(...)"` unresolved.
// Alpine then strips x-cloak but can't evaluate `x-show="open"`, so the palette renders as a
// full-screen, undismissable overlay. So: construct only when a real config is present, guard
// it, and (re)try on navigation since the config first appears on app-layout pages.
function ensureEcho() {
if (window.Echo) return; // singleton — construct once
const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {};
const key = rc.key ?? import.meta.env.VITE_REVERB_APP_KEY;
const host = rc.host ?? import.meta.env.VITE_REVERB_HOST;
if (! key || ! host) return; // no realtime config (e.g. auth pages) — skip, never throw
const scheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https';
const port = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (scheme === 'https' ? 443 : 80));
// Channels are PRIVATE: Echo POSTs to /broadcasting/auth (web group → PanelScheme + session)
// before subscribing, so we must send the CSRF token the layout exposes as <meta name="csrf-token">.
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
try {
window.Echo = new Echo({
broadcaster: 'reverb',
key,
wsHost: host,
wsPort: port,
wssPort: port,
forceTLS: scheme === 'https',
enabledTransports: ['ws', 'wss'],
auth: { headers: { 'X-CSRF-TOKEN': csrfToken } },
});
} catch (e) {
// A realtime/websocket misconfig must never take down the rest of the UI.
console.error('[clusev] realtime (Echo/Reverb) init failed — continuing without live updates', e);
}
}
ensureEcho();
// The reverb config first appears when an app-layout page mounts; on a SPA navigation INTO it
// (e.g. forced password change → dashboard) this module does not re-run, so try again then.
document.addEventListener('livewire:navigated', ensureEcho);
// ── Modal-open detection ────────────────────────────────────────────────
// wire-elements/modal emits NO "opened" event; the only reliable, package-version-
// agnostic signal is that it adds `overflow-y-hidden` to <body> in setShowPropertyTo(true).
// watchModalOpen() resolves the moment that class appears, or fires onDone(true) after
// `timeout` ms so a silently-failed open (e.g. a click that raced a morph) still surfaces
// an error instead of a dead button. Returns a silent cancel() for teardown.
function watchModalOpen(timeout, onDone) {
let settled = false;
let timer = null;
const settle = (timedOut) => {
if (settled) return;
settled = true;
obs.disconnect();
if (timer) clearTimeout(timer);
onDone?.(timedOut);
};
const obs = new MutationObserver(() => {
if (document.body.classList.contains('overflow-y-hidden')) settle(false);
});
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
timer = setTimeout(() => settle(true), timeout);
return () => { // silent teardown (component destroyed) — no onDone
if (settled) return;
settled = true;
obs.disconnect();
if (timer) clearTimeout(timer);
};
}
// Surface a red (level:error) toast through the shared toaster (partials/toaster.blade.php).
function fireErrorToast(message) {
window.dispatchEvent(new CustomEvent('notify', { detail: { message, level: 'error' } }));
}
// Last time ANY Livewire request was sent. A modal trigger uses this to tell a genuine
// client-side resolution failure (the click never reached the server — NO commit, e.g. a
// click that raced a morph) apart from a legitimate server-side no-op (the method ran and
// chose not to open a modal, or rendered a validation error — which DID commit). Only the
// former warrants the "could not open" toast.
//
// Deliberately GLOBAL (not filtered to the modal-manager component): a server method that
// no-ops (e.g. confirmTlsMode on the already-active mode) commits the PAGE component, not the
// manager — filtering to the manager would wrongly flag those as failures (false-positive
// toast). The accepted cost is the reverse: on a polling page a poll commit landing inside the
// watchdog window can mask a genuinely-lost click (missed toast). We err toward never crying
// wolf — the spinner still showed, so the click never looks dead.
let lastLivewireCommitAt = 0;
document.addEventListener('livewire:init', () => {
window.Livewire.hook('commit', () => { lastLivewireCommitAt = Date.now(); });
});
document.addEventListener('alpine:init', () => {
// ── wire-elements/modal close-path guard (vendor bug workaround) ─────
// The package's modal.js closingModal() reads components[activeComponent].name
// with NO null-guard — unlike its sibling getActiveComponentModalAttribute(),
// which DOES guard the very same access (`components[activeComponent] !== undefined`).
// When Alpine's `activeComponent` and the Livewire-side `components` map are
// momentarily out of sync at close time — a window that @persist'ing the modal
// manager across wire:navigate widens (the manager + its Alpine state survive a
// navigation while Livewire re-hydrates `components`) — that read throws
// TypeError: Cannot read properties of undefined (reading 'name')
// on the click-away path (closeModalOnClickAway → closingModal). The modal still
// closes, so it is console noise, but it violates R12 (zero console errors).
//
// We wrap the global factory to apply the SAME guard the vendor already uses
// elsewhere: when there is no live component for the active id there is nothing to
// notify and nothing can veto the close, so we report "closing allowed" (return
// true) and let closeModal() proceed exactly as before. The in-sync path is
// untouched (delegates to the original closingModal, preserving a modal's ability
// to veto its own close). Runs at alpine:init — before Alpine evaluates the
// persisted manager's x-data="LivewireUIModal()" — so every instance is guarded.
// Keeps the @persist navigate fix and the <x-modal-trigger> spinner/error-toast UX
// intact (this file is the only thing touched).
const modalFactory = window.LivewireUIModal;
if (typeof modalFactory === 'function' && !modalFactory.__clusevGuarded) {
const guarded = function () {
const data = modalFactory();
const original = data.closingModal;
if (typeof original === 'function') {
data.closingModal = function (eventName) {
const components = this.$wire?.get('components');
if (!components || components[this.activeComponent] === undefined) {
return true; // desync / already torn down: nothing to notify, allow close
}
return original.call(this, eventName);
};
}
return data;
};
guarded.__clusevGuarded = true;
window.LivewireUIModal = guarded;
}
// ── Onboarding tour ─────────────────────────────────────────────────
// First-run spotlight tour. `steps` come from the Livewire component; a step with a `target`
// spotlights the sidebar nav item carrying data-tour="<target>", otherwise the card is centred.
// Auto-opens once (autostart), relaunchable via the `onboarding:start` window event; finishing
// or skipping calls $wire.markSeen() so it never auto-opens again.
window.Alpine.data('onboardingTour', (steps = [], autostart = false) => ({
steps,
open: false,
i: 0,
rect: null, // spotlight rect (fixed-viewport coords) or null → centred card
get step() { return this.steps[this.i] || {}; },
get isLast() { return this.i === this.steps.length - 1; },
init() {
this._start = () => this.launch();
this._reflow = () => { if (this.open) this.place(); };
window.addEventListener('onboarding:start', this._start);
window.addEventListener('resize', this._reflow);
window.addEventListener('scroll', this._reflow, true);
// Auto-open once — but not if it was already dismissed this session (guards the window
// between finish() and markSeen() persisting, e.g. a fast reload). @persist covers navigation.
if (autostart && !this._dismissed()) this.$nextTick(() => this.launch());
},
_dismissed() { try { return sessionStorage.getItem('clusev.tour.dismissed') === '1'; } catch (_) { return false; } },
destroy() {
window.removeEventListener('onboarding:start', this._start);
window.removeEventListener('resize', this._reflow);
window.removeEventListener('scroll', this._reflow, true);
},
launch() { this.i = 0; this.open = true; this.$nextTick(() => this.place()); },
place() {
const t = this.step.target;
if (!t) { this.rect = null; return; }
const el = document.querySelector(`[data-tour="${t}"]`);
const r = el && el.getBoundingClientRect();
// Hidden target (e.g. the mobile drawer is closed) → fall back to a centred card.
this.rect = (r && r.width > 0 && r.height > 0) ? { top: r.top, left: r.left, width: r.width, height: r.height } : null;
},
next() { if (this.isLast) return this.finish(); this.i++; this.$nextTick(() => this.place()); },
prev() { if (this.i > 0) { this.i--; this.$nextTick(() => this.place()); } },
skip() { this.finish(); },
finish() {
this.open = false;
this.rect = null;
try { sessionStorage.setItem('clusev.tour.dismissed', '1'); } catch (_) {}
try { this.$wire.markSeen(); } catch (_) {}
},
spotStyle() {
const r = this.rect; if (!r) return 'display:none';
const p = 6;
return `top:${r.top - p}px;left:${r.left - p}px;width:${r.width + p * 2}px;height:${r.height + p * 2}px`;
},
cardStyle() {
const r = this.rect; if (!r) return '';
const gap = 18, w = 380, h = 250;
// Prefer to the right of the target (the sidebar sits on the left); flip left if it overflows.
let left = r.left + r.width + gap;
if (left + w > window.innerWidth - 12) left = Math.max(12, r.left - w - gap);
const top = Math.max(12, Math.min(r.top, window.innerHeight - h - 12));
return `top:${top}px;left:${left}px`;
},
}));
// ── Modal trigger ───────────────────────────────────────────────────
// Reused by <x-modal-trigger>. Gives EVERY modal-opening control an immediate
// spinner (pending flips synchronously on click — no round-trip needed) that clears
// the instant the modal mounts, and an error toast if it never appears within the
// timeout. open() = client-side dispatch; arm() = a server wire:click method that
// dispatches openModal itself (we only watch + spin).
window.Alpine.data('modalTrigger', (component = null, args = {}, failMsg = '', timeout = 2200) => ({
pending: false,
_cancel: null,
open() {
if (this.pending) return;
this._begin();
window.Livewire?.dispatch('openModal', { component, arguments: args });
},
arm() {
if (this.pending) return;
this._begin();
},
_begin() {
this.pending = true;
const clickedAt = Date.now();
this._cancel = watchModalOpen(this._timeout(), (timedOut) => {
this.pending = false;
this._cancel = null;
// Error ONLY when the modal never opened AND nothing committed since the click
// (a true silent failure — the click didn't reach the server). A server method
// that legitimately committed without opening (no-op / validation) stays quiet;
// it surfaces its own feedback via Livewire.
if (timedOut && lastLivewireCommitAt < clickedAt) fireErrorToast(failMsg);
});
},
_timeout() {
return Number.isFinite(timeout) && timeout > 0 ? timeout : 2200;
},
destroy() {
if (this._cancel) {
this._cancel();
this._cancel = null;
}
this.pending = false;
},
}));
// Live dual-series chart (CPU + MEM). Seeds from server-rendered data, then
// appends each MetricsTicked broadcast and redraws (viewBox 0 0 300 100).
window.Alpine.data('dualChart', (cpuSeed = [], memSeed = [], max = 40, server = null) => ({
cpu: cpuSeed.slice(-max),
mem: memSeed.slice(-max),
max,
server,
connected: false,
init() {
const conn = window.Echo?.connector?.pusher?.connection;
this.connected = conn?.state === 'connected';
this._onState = (s) => { this.connected = s.current === 'connected'; };
conn?.bind('state_change', this._onState);
// PRIVATE channel: authorized via /broadcasting/auth (see routes/channels.php).
this._channel = window.Echo?.private('metrics');
this._channel?.listen('.tick', (e) => {
// only react to the server this chart is showing
if (this.server && e.server && e.server !== this.server) return;
this.append(this.cpu, e.cpu);
this.append(this.mem, e.mem);
});
},
destroy() {
// Echo.leave() tears down the public, private- and presence- variants of the
// name, so this correctly unsubscribes the private-metrics channel.
window.Echo?.leave('metrics');
window.Echo?.connector?.pusher?.connection?.unbind('state_change', this._onState);
},
append(arr, v) {
const n = Number(v);
if (! Number.isFinite(n)) return;
arr.push(n);
if (arr.length > this.max) arr.shift();
},
_coords(arr) {
const n = arr.length, w = 300, h = 100, pad = 6;
return arr
.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * w : 0;
const y = h - pad - (Math.max(0, Math.min(100, v)) / 100) * (h - 2 * pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
},
get cpuLine() { return this._coords(this.cpu); },
get cpuArea() { return `0,100 ${this._coords(this.cpu)} 300,100`; },
get memLine() { return this._coords(this.mem); },
get memArea() { return `0,100 ${this._coords(this.mem)} 300,100`; },
}));
// Persistent history chart (Server-Details). Self-contained island: fetches /servers/<uuid>/
// history.json per range, draws smooth (Catmull-Rom) lines + area fills, a hover crosshair +
// tooltip, and switches range client-side (no server round-trip). The line is continuous and
// breaks only on a REAL gap (Δt > 2.5× the bucket width = the server was actually down).
window.Alpine.data('metricChart', (uuid) => ({
uuid,
range: '24h',
loading: true,
data: { points: [], from: 0, now: 0, bucket: 60 },
hover: null,
W: 1000,
H: 260,
ranges: ['1h', '24h', '7d', '30d'],
init() {
// Restore the range from the URL (?range=) so a reload of the SAME page keeps the choice;
// opening the page fresh (no param) falls back to the 24h default.
const r = new URL(window.location.href).searchParams.get('range');
if (this.ranges.includes(r)) this.range = r;
this.reload();
this._timer = setInterval(() => this.reload(true), 60000); // keep it fresh
},
destroy() { clearInterval(this._timer); },
setRange(r) {
if (r === this.range || ! this.ranges.includes(r)) return;
this.range = r;
// Reflect the choice in the URL (replace, not push — no history spam) so it survives a reload.
const url = new URL(window.location.href);
url.searchParams.set('range', r);
window.history.replaceState(window.history.state, '', url);
this.reload();
},
reload(silent = false) {
if (! silent) this.loading = true;
fetch(`/servers/${this.uuid}/history.json?range=${this.range}`, { headers: { Accept: 'application/json' }, cache: 'no-store' })
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d && Array.isArray(d.points)) this.data = d; this.loading = false; })
.catch(() => { this.loading = false; });
},
get hasData() { return (this.data.points || []).length > 0; },
_x(t) { const span = Math.max(1, this.data.now - this.data.from); return ((t - this.data.from) / span) * this.W; },
_y(v) { return this.H - (Math.max(0, Math.min(100, v)) / 100) * this.H; },
// Split points into continuous runs; a Δt larger than 2.5× the bucket = a real outage → break.
_runs(key) {
const pts = this.data.points || [], bucket = this.data.bucket || 60, runs = [];
let cur = [];
for (let i = 0; i < pts.length; i++) {
if (cur.length && (pts[i].t - pts[i - 1].t) > bucket * 2.5) { runs.push(cur); cur = []; }
cur.push({ x: this._x(pts[i].t), y: this._y(pts[i][key]) });
}
if (cur.length) runs.push(cur);
return runs;
},
// Catmull-Rom → cubic-bezier smoothing for an organic, non-angular curve.
_smooth(p) {
if (p.length < 2) return p.length ? `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)}` : '';
let d = `M${p[0].x.toFixed(1)},${p[0].y.toFixed(1)}`;
for (let i = 0; i < p.length - 1; i++) {
const p0 = p[i - 1] || p[i], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2] || p2;
const c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d;
},
line(key) { return this._runs(key).map((r) => this._smooth(r)).join(' '); },
area(key) {
return this._runs(key).map((r) => (r.length
? `${this._smooth(r)} L${r[r.length - 1].x.toFixed(1)},${this.H} L${r[0].x.toFixed(1)},${this.H} Z`
: '')).join(' ');
},
onMove(ev) {
const pts = this.data.points || [];
if (! pts.length) return;
const rect = ev.currentTarget.getBoundingClientRect();
const tx = this.data.from + ((ev.clientX - rect.left) / Math.max(1, rect.width)) * (this.data.now - this.data.from);
let bi = 0, bd = Infinity;
for (let i = 0; i < pts.length; i++) { const dd = Math.abs(pts[i].t - tx); if (dd < bd) { bd = dd; bi = i; } }
this.hover = { i: bi, p: pts[bi], left: (this._x(pts[bi].t) / this.W) * 100 };
},
onLeave() { this.hover = null; },
fmtTime(t) {
return new Date(t * 1000).toLocaleString([], { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
},
}));
// Interactive terminal (xterm.js). xterm + its CSS are dynamically imported so they only load on
// the terminal page (code-split chunk). On a 'terminal-open' event (from the Livewire rail) it
// opens a same-origin WebSocket to /terminal/ws?token=… — the sidecar holds the PTY. A real PTY
// means native shell features work, including Tab/Shift-Tab autocompletion.
window.Alpine.data('terminalView', () => ({
term: null,
fit: null,
ws: null,
status: 'idle', // idle | connecting | connected | closed | error
title: '',
_ro: null,
_pending: null, // a token that arrived before xterm finished its async load
async init() {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
]);
await import('@xterm/xterm/css/xterm.css');
this.term = new Terminal({
fontFamily: "'JetBrains Mono', ui-monospace, 'SF Mono', monospace",
fontSize: 13,
cursorBlink: true,
scrollback: 5000,
theme: {
background: '#0B0E12', foreground: '#E9EEF3', cursor: '#FF6B2C', cursorAccent: '#0B0E12',
selectionBackground: 'rgba(255,107,44,0.25)',
black: '#0F1318', red: '#FF5247', green: '#35D07F', yellow: '#E8B931',
blue: '#43C1D8', magenta: '#FF8A4D', cyan: '#74D7E8', white: '#9DAAB6',
brightBlack: '#707B85', brightWhite: '#E9EEF3',
},
});
this.fit = new FitAddon();
this.term.loadAddon(this.fit);
this.term.open(this.$refs.screen);
// Fit now, next frame (after layout), and again once the web font finishes loading.
// The last one is critical: FitAddon measures the cell width to pick the column count;
// if it runs while JetBrains Mono is still loading it measures the (narrower) fallback,
// over-counts columns, then the wider real font overflows and the right edge gets clipped.
this._refit();
this.term.onData((d) => { if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify({ type: 'data', data: d })); });
this._ro = new ResizeObserver(() => { try { this.fit.fit(); this._sendResize(); } catch (_) {} });
this._ro.observe(this.$refs.screen);
// A target picked before xterm finished loading is connected now.
if (this._pending) { const t = this._pending; this._pending = null; this._connect(t); }
},
// Re-measure + resize the grid to the container: immediately, after the next layout frame, and
// once web fonts settle (so column math uses the real monospace metrics, not a fallback).
_refit() {
const f = () => { try { this.fit.fit(); this._sendResize(); } catch (_) {} };
f();
requestAnimationFrame(f);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(f).catch(() => {});
},
destroy() {
this._close();
if (this._ro) this._ro.disconnect();
try { if (this.term) this.term.dispose(); } catch (_) {}
},
open(detail) {
if (!detail || !detail.token) return;
this.title = detail.title || '';
if (this.term) this._connect(detail.token);
else this._pending = detail.token; // xterm still loading → connect on init
},
_connect(token) {
this._close();
this.status = 'connecting';
if (this.term) this.term.reset();
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
// Hand the current grid size to the sidecar so the PTY (and the remote shell's MOTD/prompt)
// is born at the visible width — otherwise it starts at 80×24 and the first screenful wraps
// awkwardly on a narrow (mobile) viewport until the first resize lands.
const cols = (this.term && this.term.cols) || 80;
const rows = (this.term && this.term.rows) || 24;
const ws = new WebSocket(`${proto}://${window.location.host}/terminal/ws?token=${encodeURIComponent(token)}&cols=${cols}&rows=${rows}`);
this.ws = ws;
// Every handler is bound to THIS socket: a superseded connection (the user picked another
// target) still fires onclose/onerror asynchronously, which would otherwise clobber the
// freshly-connecting terminal's state. Ignore any event from a socket we've moved past.
ws.onmessage = (ev) => {
if (this.ws !== ws) return;
let m;
try { m = JSON.parse(ev.data); } catch (_) { return; }
if (m.type === 'data') this.term.write(m.data);
else if (m.type === 'ready') { this.status = 'connected'; this._refit(); setTimeout(() => this.term.focus(), 0); }
else if (m.type === 'error') { this.status = 'error'; this.term.write(`\r\n\x1b[31m${m.data}\x1b[0m\r\n`); }
else if (m.type === 'exit') { this.status = 'closed'; this.term.write('\r\n\x1b[90m— Sitzung beendet —\x1b[0m\r\n'); }
};
ws.onclose = () => { if (this.ws !== ws) return; if (this.status === 'connected' || this.status === 'connecting') { this.status = 'closed'; this.term.write('\r\n\x1b[90m— Verbindung getrennt —\x1b[0m\r\n'); } };
ws.onerror = () => { if (this.ws !== ws) return; if (this.status !== 'closed') this.status = 'error'; };
},
_sendResize() {
if (this.ws && this.ws.readyState === 1 && this.term) this.ws.send(JSON.stringify({ type: 'resize', cols: this.term.cols, rows: this.term.rows }));
},
_close() {
if (this.ws) { try { this.ws.close(); } catch (_) {} this.ws = null; }
},
clear() { if (this.term) this.term.clear(); },
}));
// ── Command palette + keyboard shortcuts ────────────────────────────
// Nav + action item labels are passed in from the Blade markup (already
// translated via __()), so the palette stays in sync with the active locale.
// CMDK_GO maps leader-key mnemonics to routes (i=dIenste, l=Log,
// e=Einstellungen, y=sYstem — d/s already taken) and stays in JS.
// `h` (Help) is intentionally absent — it resolves via the config-driven nav list in go()
// so it can point at the external docs URL instead of an in-app route.
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', t: '/terminal' };
window.Alpine.data('cmdk', (nav = [], actions = [], servers = [], failMsg = '') => ({
nav,
actions,
servers,
failMsg,
open: false,
help: false,
query: '',
active: 0,
leader: false,
leaderTimer: null,
_cancelModalWatch: null,
init() {
// Reset overlays after an SPA navigation. The component is recreated on each
// wire:navigate, so we MUST remove this document listener in destroy() — else
// every navigation would accumulate another permanent callback.
this._onNav = () => {
this.open = false;
this.help = false;
this.leader = false;
};
document.addEventListener('livewire:navigated', this._onNav);
},
destroy() {
document.removeEventListener('livewire:navigated', this._onNav);
if (this._cancelModalWatch) {
this._cancelModalWatch();
this._cancelModalWatch = null;
}
},
get items() {
const q = this.query.trim().toLowerCase();
// Empty query stays compact (nav + actions). Servers join the pool only while
// searching, matched on name OR IP (the `search` field), so the fleet never
// clutters the default list but is one keystroke away.
const base = [...this.nav, ...this.actions];
if (q === '') return base;
return [...base, ...this.servers].filter((i) => (i.search || i.label).toLowerCase().includes(q));
},
toggle(force) {
this.open = force === true ? true : ! this.open;
if (this.open) {
this.help = false;
this.query = '';
this.active = 0;
this.$nextTick(() => this.$refs.input?.focus());
}
},
close() {
this.open = false;
this.help = false;
},
move(d) {
const n = this.items.length;
if (n) this.active = (this.active + d + n) % n;
},
run(item) {
item = item || this.items[this.active] || null;
if (! item) return;
if (item.href) {
this.navigate(item.href);
} else if (item.action === 'create-server') {
this.close();
// No per-button spinner here (the palette closes), but still surface an error
// toast if the modal never appears AND nothing committed (same discriminator as
// modalTrigger). Store the canceller so destroy() can tear it down on navigation.
if (this._cancelModalWatch) this._cancelModalWatch(); // tear down any in-flight watcher first
const clickedAt = Date.now();
window.Livewire?.dispatch('openModal', { component: 'modals.create-server' });
const watch = watchModalOpen(2200, (timedOut) => {
if (this._cancelModalWatch === watch) this._cancelModalWatch = null; // only clear our own handle
if (timedOut && lastLivewireCommitAt < clickedAt) fireErrorToast(this.failMsg);
});
this._cancelModalWatch = watch;
}
},
navigate(href) {
this.close();
// Absolute URLs (e.g. the online docs) open in a new tab; in-app routes use SPA nav.
if (/^https?:\/\//i.test(href)) { window.open(href, '_blank', 'noopener'); return; }
if (window.Livewire?.navigate) window.Livewire.navigate(href);
else window.location.href = href;
},
focusSearch() {
const el = document.querySelector('[data-page-search]');
if (el) {
el.focus();
return true;
}
return false;
},
go(key) {
if (CMDK_GO[key]) { this.navigate(CMDK_GO[key]); return; }
// Keys not in the static map resolve via the (translated, config-driven) nav list —
// e.g. "g h" → the Help entry, whose href points at the online docs.
const item = this.nav.find((n) => n.hint === `g ${key}`);
if (item?.href) this.navigate(item.href);
},
onKey(e) {
// Cmd/Ctrl-K toggles the palette even from within an input.
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.toggle();
return;
}
if (this.open || this.help) return; // overlay keys handled in the markup
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
// Only consume "/" when a page search exists — otherwise leave the browser's quick-find.
if (e.key === '/') { if (this.focusSearch()) e.preventDefault(); return; }
if (e.key === '?') { e.preventDefault(); this.help = true; return; }
if (this.leader) {
this.leader = false;
clearTimeout(this.leaderTimer);
this.go(e.key.toLowerCase());
return;
}
if (e.key === 'g' || e.key === 'G') {
this.leader = true;
this.leaderTimer = setTimeout(() => { this.leader = false; }, 1200);
}
},
}));
});
// ── Console branding + self-XSS warning ─────────────────────────────────────────────────────────
// Printed once on load. The security notice is not decoration: a control panel for Linux servers is
// a prime target for "paste this in your console" social-engineering (self-XSS) — surfacing the
// warning where the attack happens protects admins. Styling is best-effort; never throws.
(() => {
try {
const big = 'color:#FF6B2C;font:800 44px/1.1 "JetBrains Mono",ui-monospace,monospace;text-shadow:0 0 14px rgba(255,107,44,.45);padding:6px 0';
const sub = 'color:#9DAAB6;font:13px ui-monospace,monospace';
const warnH = 'color:#FF5247;font:700 15px ui-monospace,monospace';
const warnB = 'color:#E8B931;font:12px/1.5 ui-monospace,monospace';
const link = 'color:#43C1D8;font:12px ui-monospace,monospace';
console.log('%cCLUSEV', big);
console.log('%cSelf-hosted control plane für deine Linux-Flotte.', sub);
console.log('%c⚠ Stopp — diese Konsole ist für Entwickler.', warnH);
console.log('%cFüg hier NICHTS ein, das dir jemand geschickt hat. Solche „Tricks" sind fast\nimmer Betrug (Self-XSS) und können deine Sitzung und deine Server übernehmen.', warnB);
console.log('%cNeugierig? Fragen? Mitbauen? → https://github.com/clusev/clusev · AGPL-3.0', link);
} catch (_) {
// console styling unsupported — ignore
}
})();