474 lines
21 KiB
JavaScript
474 lines
21 KiB
JavaScript
import './echo';
|
|
import { Chart, registerables } from 'chart.js';
|
|
|
|
Chart.register(...registerables);
|
|
|
|
const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
// Resolve "token:name" / "token:name/0.12" strings in a chart config to real
|
|
// colours pulled from the CSS design tokens (keeps charts on-palette, R3).
|
|
function tokenColor(spec, css) {
|
|
const [name, alpha] = spec.split('/');
|
|
const value = css.getPropertyValue('--' + name.trim()).trim();
|
|
if (!value) return spec;
|
|
if (alpha && value.startsWith('#')) {
|
|
let hex = value.slice(1);
|
|
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
|
const r = parseInt(hex.slice(0, 2), 16);
|
|
const g = parseInt(hex.slice(2, 4), 16);
|
|
const b = parseInt(hex.slice(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function resolveTokens(node, css) {
|
|
if (typeof node === 'string') return node.startsWith('token:') ? tokenColor(node.slice(6), css) : node;
|
|
if (Array.isArray(node)) return node.map((n) => resolveTokens(n, css));
|
|
if (node && typeof node === 'object') {
|
|
const out = {};
|
|
for (const key of Object.keys(node)) out[key] = resolveTokens(node[key], css);
|
|
return out;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
// Turn any resolved colour (#hex, rgb(), rgba()) into an rgba() with the given alpha.
|
|
function toRgba(color, alpha) {
|
|
if (typeof color !== 'string') return color;
|
|
if (color.startsWith('#')) {
|
|
let hex = color.slice(1);
|
|
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
|
const r = parseInt(hex.slice(0, 2), 16);
|
|
const g = parseInt(hex.slice(2, 4), 16);
|
|
const b = parseInt(hex.slice(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
const m = color.match(/rgba?\(([^)]+)\)/);
|
|
if (m) {
|
|
const [r, g, b] = m[1].split(',').map((n) => n.trim());
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
}
|
|
return color;
|
|
}
|
|
|
|
// Every filled line dataset gets a vertical gradient from its line colour to
|
|
// transparent — the "colour fades to nothing under the line" look (R3/token-driven).
|
|
function applyLineGradients(cfg) {
|
|
const isLine = (ds) => (ds.type || cfg.type) === 'line';
|
|
for (const ds of cfg.data?.datasets ?? []) {
|
|
if (!isLine(ds) || !ds.fill) continue;
|
|
const base = Array.isArray(ds.borderColor) ? ds.borderColor[0] : ds.borderColor;
|
|
if (!base) continue;
|
|
ds.backgroundColor = (ctx) => {
|
|
const { chart } = ctx;
|
|
const area = chart.chartArea;
|
|
if (!area) return toRgba(base, 0.18); // first paint before layout
|
|
const g = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
|
|
g.addColorStop(0, toRgba(base, 0.32));
|
|
g.addColorStop(1, toRgba(base, 0));
|
|
return g;
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Connection health: a silent 419 recovery, and a banner for everything
|
|
// that is not a Livewire-recognised failure at all ─────────────────────
|
|
//
|
|
// Livewire's OWN default handling of a failed request (vendor/livewire/
|
|
// livewire/dist/livewire.js, js/request/index.js — read directly, not
|
|
// assumed, since the previous comment here turned out to be wrong about it)
|
|
// is not what it looks like from the outside:
|
|
//
|
|
// - A 419 (expired session or a stale CSRF token) triggers a native
|
|
// "This page has expired…" browser dialog (window's own confirm prompt)
|
|
// and THEN, whatever the operator answers, the same full-page HTML
|
|
// overlay used for a 500 — both fire unconditionally unless the request
|
|
// hook's preventDefault() is called.
|
|
// - A request that never gets a response at all (offline, DNS failure,
|
|
// connection refused — fetch() itself throwing) is already silent: no
|
|
// overlay, no dialog, nothing. Correct for case 3 below, but it also
|
|
// means a dropped connection currently gives the operator no feedback
|
|
// whatsoever — the gap the banner fills.
|
|
// - Anything else (500 and friends) is untouched — Livewire's own overlay
|
|
// is the right behaviour for a real server error; this project shows
|
|
// failures rather than hiding them (R22).
|
|
//
|
|
// preventDefault() runs synchronously inside Livewire's own fail(), before
|
|
// it decides whether to show that dialog and the overlay — so calling it
|
|
// here is not a race against window.location.reload() the way triggering
|
|
// the reload alone (the previous approach) was.
|
|
//
|
|
// A page left open across a deploy, or simply idle long enough, carries a
|
|
// CSRF token the server no longer accepts. Reloading gets a fresh one for a
|
|
// session that is still valid, and lands an actually-expired session
|
|
// straight on the sign-in page — both through completely ordinary Laravel
|
|
// middleware, nothing bespoke.
|
|
//
|
|
// sessionStorage survives the reload (same tab) but not a closed one:
|
|
// remembering that a reload was already tried means a SECOND 419 inside the
|
|
// window below is treated as "reloading did not fix it" rather than looped
|
|
// on forever, and sends the operator to sign in directly instead. A 419 in a
|
|
// fresh tab, or one long after the window has passed, always gets its own
|
|
// reload first.
|
|
const RELOAD_GUARD_KEY = 'clupilot:419-reload-at';
|
|
const RELOAD_GUARD_WINDOW_MS = 15000;
|
|
|
|
function recoverFrom419() {
|
|
const triedAt = Number(sessionStorage.getItem(RELOAD_GUARD_KEY) || 0);
|
|
|
|
if (Date.now() - triedAt < RELOAD_GUARD_WINDOW_MS) {
|
|
sessionStorage.removeItem(RELOAD_GUARD_KEY);
|
|
// Each shell carries its OWN sign-in route on its <body> (R21: the
|
|
// console and the portal do not share one) — see layouts/admin.blade.php
|
|
// and layouts/portal-app.blade.php.
|
|
window.location.href = document.body.dataset.loginUrl || '/login';
|
|
|
|
return;
|
|
}
|
|
|
|
sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now()));
|
|
window.location.reload();
|
|
}
|
|
|
|
document.addEventListener('livewire:init', () => {
|
|
window.Livewire.hook('request', ({ fail }) => {
|
|
fail(({ status, preventDefault }) => {
|
|
if (status !== 419) return;
|
|
|
|
preventDefault();
|
|
recoverFrom419();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Connection banner ("Keine Verbindung" / "Verbindung wiederhergestellt" —
|
|
// see connectionBanner() below): navigator.onLine and the browser's
|
|
// online/offline events are both used, but neither is trusted alone —
|
|
// onLine is only ever a hint (true on a wifi with no real route out, a
|
|
// captive portal, a VPN that dropped without the OS noticing), which is why
|
|
// an actual Livewire request completing or failing at the network level is
|
|
// wired in as a third, stronger signal.
|
|
//
|
|
// Wrapping fetch (module load, before Livewire's own bundle sends anything)
|
|
// rather than reading the request hook's fail() for this part: fail() only
|
|
// fires for a request Livewire itself made, and represents a thrown fetch as
|
|
// a synthetic status-503 failure — recognisable, but wrapping fetch directly
|
|
// observes the same thrown error without leaning on that internal detail.
|
|
const originalFetch = window.fetch.bind(window);
|
|
window.fetch = async (...args) => {
|
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
|
|
const isLivewireRequest = url.includes('/livewire/');
|
|
|
|
let response;
|
|
try {
|
|
response = await originalFetch(...args);
|
|
} catch (error) {
|
|
if (isLivewireRequest) window.dispatchEvent(new CustomEvent('connection-lost'));
|
|
throw error;
|
|
}
|
|
|
|
// Any response at all — even a 500 — proves the network path to our own
|
|
// server works, which is all "back online" means here.
|
|
if (isLivewireRequest) window.dispatchEvent(new CustomEvent('connection-restored'));
|
|
|
|
return response;
|
|
};
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
// ── Watching a deployment that restarts the watcher ──────────────────
|
|
//
|
|
// wire:poll cannot see an update through to the end, because the update
|
|
// restarts the containers serving the poll. Mid-run every request fails;
|
|
// afterwards a NEW build is being questioned by the OLD page's JavaScript.
|
|
// The card therefore sat on "running" until somebody reloaded the page by
|
|
// hand, which is how it was reported.
|
|
//
|
|
// This asks a plain JSON endpoint instead — no Livewire, no component
|
|
// state, no assets — and, crucially, treats a failed request as the
|
|
// restart rather than as an error worth giving up over.
|
|
window.Alpine.data('updateWatcher', ({
|
|
url, commit, running, step = null, runningSince = null, log = null,
|
|
}) => ({
|
|
// The build this page was rendered from. When the server stops
|
|
// agreeing, the page is stale by definition and must be replaced —
|
|
// this is the only signal that survives the restart, since the run has
|
|
// already ended by the time we can ask again.
|
|
renderedCommit: commit,
|
|
wasRunning: running,
|
|
// Has the SERVER ever reported this run, as opposed to us opening the
|
|
// overlay optimistically when the button was pressed?
|
|
//
|
|
// This is the gap the whole thing turned on. The agent consumes the
|
|
// request file BEFORE it resolves the release — deliberately, because
|
|
// update.sh may kill the shell and a request left in place would loop —
|
|
// and only writes `state: running` once it has decided to go ahead. In
|
|
// between, the request is gone and the status does not say running yet,
|
|
// so the endpoint honestly answers "nothing is running". The watcher
|
|
// read that as "the run has finished" and reloaded the page: the
|
|
// overlay appeared on the click, vanished a poll later, and the 503
|
|
// arrived after it. Reported twice, exactly that way.
|
|
//
|
|
// The run has ENDED only once the server has both confirmed it and then
|
|
// stopped reporting it. Before the confirmation, silence means the agent
|
|
// has not got there yet.
|
|
serverConfirmed: running,
|
|
unconfirmedPolls: 0,
|
|
// Consecutive failed requests. Expected during the restart — that IS
|
|
// the event — but not forever: if the application comes back broken,
|
|
// every request fails and the overlay used to stay up over a console
|
|
// nobody could then reach to find out why. Reported exactly that way,
|
|
// together with the 500 that caused it.
|
|
failedPolls: 0,
|
|
// Shown once the failures stop looking like a restart. Not a "close"
|
|
// button that is always there: the run does not stop because somebody
|
|
// dismissed a dialog, and offering that at the wrong moment is a lie.
|
|
stuck: false,
|
|
// Already translated server-side ("Schritt: …", "Läuft seit …") — the
|
|
// client only ever displays these, never assembles them, so German
|
|
// text stays out of JavaScript entirely.
|
|
step,
|
|
runningSince,
|
|
log,
|
|
timer: null,
|
|
|
|
init() {
|
|
this.schedule();
|
|
// Nothing to watch while the tab is hidden; pick straight back up
|
|
// rather than waiting out a whole interval on return.
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) this.check();
|
|
});
|
|
},
|
|
|
|
schedule() {
|
|
clearTimeout(this.timer);
|
|
this.timer = setTimeout(() => this.check(), this.wasRunning ? 3000 : 20000);
|
|
},
|
|
|
|
async check() {
|
|
try {
|
|
const response = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' });
|
|
|
|
// A redirect to the login page answers 200 with HTML. Reload
|
|
// rather than parse it: the session is what needs attention.
|
|
if (!response.ok || !response.headers.get('content-type')?.includes('json')) {
|
|
throw new Error('not ready');
|
|
}
|
|
|
|
const state = await response.json();
|
|
|
|
if (state.commit && this.renderedCommit && state.commit !== this.renderedCommit) {
|
|
window.location.reload();
|
|
|
|
return;
|
|
}
|
|
|
|
if (state.running) {
|
|
this.serverConfirmed = true;
|
|
this.unconfirmedPolls = 0;
|
|
}
|
|
|
|
// The run ended without the build changing — a check that found
|
|
// nothing, or a failure. Either way the card has something new
|
|
// to say.
|
|
if (this.wasRunning && this.serverConfirmed && !state.running) {
|
|
window.location.reload();
|
|
|
|
return;
|
|
}
|
|
|
|
// ...but an overlay that waits forever is its own bug. The
|
|
// agent refuses a request with nothing to install, and says so
|
|
// by writing nothing at all — so give it a bounded number of
|
|
// polls before concluding that nobody picked it up.
|
|
if (this.wasRunning && !this.serverConfirmed && ++this.unconfirmedPolls > 20) {
|
|
window.location.reload();
|
|
|
|
return;
|
|
}
|
|
|
|
// Stay open while we are waiting for the agent to confirm.
|
|
this.wasRunning = state.running || (this.wasRunning && !this.serverConfirmed);
|
|
this.failedPolls = 0;
|
|
this.stuck = false;
|
|
this.step = state.step ?? null;
|
|
this.runningSince = state.running_since ?? null;
|
|
this.log = state.log ?? null;
|
|
} catch {
|
|
// Expected while the containers are down. Keep asking: this is
|
|
// the middle of the very event being watched, not a fault.
|
|
this.wasRunning = true;
|
|
|
|
// A deployment restart is seconds. Two minutes of nothing is
|
|
// not a restart any more — it is an installation that came back
|
|
// broken, and the operator needs the page rather than a panel
|
|
// telling them to wait.
|
|
if (++this.failedPolls > 40) {
|
|
this.stuck = true;
|
|
}
|
|
}
|
|
|
|
this.schedule();
|
|
},
|
|
|
|
// The way out, offered only once `stuck` is true.
|
|
dismiss() {
|
|
this.wasRunning = false;
|
|
clearTimeout(this.timer);
|
|
},
|
|
|
|
destroy() {
|
|
clearTimeout(this.timer);
|
|
},
|
|
}));
|
|
|
|
// ── VPN config: copy / download ──────────────────────────────────────
|
|
// navigator.clipboard only exists in a secure context, so over plain http
|
|
// (the panel reached by IP) the copy button silently did nothing. Falls
|
|
// back to the legacy selection copy, and says so when even that fails.
|
|
window.Alpine.data('vpnConfigActions', (filename) => ({
|
|
copied: false,
|
|
failed: false,
|
|
text() {
|
|
return this.$refs.config.textContent;
|
|
},
|
|
async copy() {
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(this.text());
|
|
} else {
|
|
const area = document.createElement('textarea');
|
|
area.value = this.text();
|
|
area.setAttribute('readonly', '');
|
|
area.style.position = 'fixed';
|
|
area.style.opacity = '0';
|
|
document.body.appendChild(area);
|
|
area.select();
|
|
const ok = document.execCommand('copy');
|
|
document.body.removeChild(area);
|
|
if (!ok) throw new Error('copy rejected');
|
|
}
|
|
this.failed = false;
|
|
this.copied = true;
|
|
setTimeout(() => (this.copied = false), 2000);
|
|
} catch (e) {
|
|
this.copied = false;
|
|
this.failed = true;
|
|
setTimeout(() => (this.failed = false), 6000);
|
|
}
|
|
},
|
|
download() {
|
|
// Built in the browser from what is already on the page: the private
|
|
// key never has to travel a second time, and no route has to exist.
|
|
const url = URL.createObjectURL(new Blob([this.text()], { type: 'text/plain' }));
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
},
|
|
}));
|
|
|
|
// ── Segmented OTP input ──────────────────────────────────────────────
|
|
window.Alpine.data('otpInput', ({ length = 6 } = {}) => ({
|
|
length,
|
|
digits: Array(length).fill(''),
|
|
get value() {
|
|
return this.digits.join('');
|
|
},
|
|
cells() {
|
|
return this.$root.querySelectorAll('input[inputmode="numeric"]');
|
|
},
|
|
focusCell(i) {
|
|
const cell = this.cells()[i];
|
|
if (cell) cell.focus();
|
|
},
|
|
onInput(i, e) {
|
|
const digit = e.target.value.replace(/\D/g, '').slice(-1);
|
|
this.digits[i] = digit;
|
|
if (digit && i < this.length - 1) this.focusCell(i + 1);
|
|
this.maybeSubmit();
|
|
},
|
|
onBackspace(i) {
|
|
if (!this.digits[i] && i > 0) this.focusCell(i - 1);
|
|
},
|
|
onPaste(e) {
|
|
const text = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, this.length);
|
|
for (let i = 0; i < this.length; i++) this.digits[i] = text[i] || '';
|
|
this.focusCell(Math.min(text.length, this.length - 1));
|
|
this.maybeSubmit();
|
|
},
|
|
maybeSubmit() {
|
|
if (this.value.length === this.length) {
|
|
this.$nextTick(() => this.$root.closest('form')?.requestSubmit());
|
|
}
|
|
},
|
|
}));
|
|
|
|
// ── Chart.js island ──────────────────────────────────────────────────
|
|
window.Alpine.data('chart', (config) => ({
|
|
instance: null,
|
|
init() {
|
|
// Read tokens from this element so charts honour a scoped theme
|
|
// (e.g. .theme-admin dark tokens), not just :root.
|
|
const css = getComputedStyle(this.$el);
|
|
Chart.defaults.font.family = css.getPropertyValue('--font-sans').trim() || 'sans-serif';
|
|
Chart.defaults.color = css.getPropertyValue('--text-muted').trim() || '#667085';
|
|
Chart.defaults.borderColor = css.getPropertyValue('--border').trim() || '#e4e7ec';
|
|
|
|
const cfg = resolveTokens(config, css);
|
|
applyLineGradients(cfg);
|
|
cfg.options = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: prefersReducedMotion() ? false : cfg.options?.animation,
|
|
...cfg.options,
|
|
};
|
|
this.instance = new Chart(this.$refs.canvas, cfg);
|
|
},
|
|
destroy() {
|
|
this.instance?.destroy();
|
|
},
|
|
}));
|
|
|
|
// ── Connection banner ──────────────────────────────────────────────────
|
|
// "Keine Verbindung" while offline, "Verbindung wiederhergestellt" for a
|
|
// few seconds once a signal below says it is back, then nothing. Bound
|
|
// once per page in each shell layout, listening for both the browser's
|
|
// own online/offline events and the connection-lost/connection-restored
|
|
// events the fetch wrapper above dispatches for real Livewire traffic.
|
|
// Whichever notices first wins; a wrong optimistic "restored" from the
|
|
// browser's online event self-corrects the moment the next Livewire
|
|
// request actually fails again.
|
|
window.Alpine.data('connectionBanner', () => ({
|
|
// A page opened while already offline should say so immediately
|
|
// rather than wait for the first failed request.
|
|
offline: !navigator.onLine,
|
|
justRestored: false,
|
|
restoreTimer: null,
|
|
|
|
markOffline() {
|
|
clearTimeout(this.restoreTimer);
|
|
this.justRestored = false;
|
|
this.offline = true;
|
|
},
|
|
|
|
markOnline() {
|
|
// Nothing to announce if this page was never marked offline.
|
|
if (!this.offline) return;
|
|
|
|
this.offline = false;
|
|
this.justRestored = true;
|
|
clearTimeout(this.restoreTimer);
|
|
this.restoreTimer = setTimeout(() => { this.justRestored = false; }, 3000);
|
|
},
|
|
|
|
destroy() {
|
|
clearTimeout(this.restoreTimer);
|
|
},
|
|
}));
|
|
});
|