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 (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 }) => { // 503: the application is in maintenance mode, which during a // deployment is not a fault — it is the event being watched. // // Livewire's default is to replace the document with the response // body, so a wire:poll that happened to fire mid-update swapped the // console for Laravel's 503 page: the operator saw the overlay, // then the error page, then the reload. Reported as "the 503 must // not appear at all during the update", and it must not. // // Swallowed for every Livewire request, not just the poll: any // action taken in that window would do the same thing. if (status === 503) { preventDefault(); return; } 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); }, })); });