507 lines
24 KiB
JavaScript
507 lines
24 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 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;
|
||
}
|
||
|
||
// ── 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' });
|
||
},
|
||
}));
|
||
|
||
// ── 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
|
||
}
|
||
})();
|