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 . 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 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 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; } // ── Modal trigger ─────────────────────────────────────────────────── // Reused by . 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`; }, })); // ── 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. const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions', h: '/help' }; 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(); 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]); }, 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 } })();