clusev/resources/js/app.js

225 lines
9.2 KiB
JavaScript

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 layout
// (window.__clusev.reverb), derived from the effective panel domain — so changing the
// domain from the dashboard needs no JS rebuild. Falls back to Vite env for HMR/dev.
const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {};
const reverbScheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https';
const reverbPort = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (reverbScheme === '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;
window.Echo = new Echo({
broadcaster: 'reverb',
key: rc.key ?? import.meta.env.VITE_REVERB_APP_KEY,
wsHost: rc.host ?? import.meta.env.VITE_REVERB_HOST,
wsPort: reverbPort,
wssPort: reverbPort,
forceTLS: reverbScheme === 'https',
enabledTransports: ['ws', 'wss'],
auth: { headers: { 'X-CSRF-TOKEN': csrfToken } },
});
document.addEventListener('alpine:init', () => {
// 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' };
window.Alpine.data('cmdk', (nav = [], actions = []) => ({
nav,
actions,
open: false,
help: false,
query: '',
active: 0,
leader: false,
leaderTimer: 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);
},
get items() {
const q = this.query.trim().toLowerCase();
const all = [...this.nav, ...this.actions];
return q === '' ? all : all.filter((i) => 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();
window.Livewire?.dispatch('openModal', { component: 'modals.create-server' });
}
},
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);
}
},
}));
});
// Keep the active-server session in sync with the server-details page on screen, even
// when wire:navigate restores a page from its bfcache (Back/Forward) without re-running
// Show::mount(). The details page tags its root with data-clusev-activate=<activate URL>.
let clusevActivateChain = Promise.resolve();
document.addEventListener('livewire:navigated', () => {
const el = document.querySelector('[data-clusev-activate]');
if (!el) return;
const url = el.dataset.clusevActivate;
const token = document.querySelector('meta[name="csrf-token"]')?.content ?? '';
// SERIALIZE: run each activation after the previous one resolves, so the LATEST
// navigation's request is guaranteed to reach the server last — a stale activation
// can never overwrite a newer selection. A per-request timeout prevents a hung
// request from stalling the chain.
clusevActivateChain = clusevActivateChain.then(() => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
return fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': token, 'X-Requested-With': 'XMLHttpRequest' },
signal: ctrl.signal,
}).catch(() => {}).finally(() => clearTimeout(timer));
});
});