17 Commits (v1.3.60)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
57c6912987 |
Say that a click landed, and make moving one actually move it
tests / pest (push) Failing after 7m52s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
"Man klickt und sieht nicht was passiert." Every action in this console is a round trip, and a round trip with no sign of itself reads as a click that missed — so the answer is in three places, from the general to the specific. A thin bar at the top of the window for any Livewire request, in app.js. Delayed by 140 ms, because a request that answers faster than that is perceived as immediate and a bar flashing on every keystroke of a .live field is worse than none. It creeps to 70 % and only completes on the answer: a bar that reaches the end while the answer is still on its way is a lie about the thing it exists to report. Released on failure as well — the request somebody must not be left waiting on is precisely the one that went wrong — and on wire:navigate, which is not a request hook at all and would otherwise leave the bar behind on a page that is gone. Per button: a pressed state on the way down, disabled while its own request is out, scoped with wire:target to that button's own call so a row does not grey itself out because a neighbour is moving. The edit button carries its own spinner, because opening the modal is a request made by the MODAL component — wire:loading on this page never sees it. It clears when the package announces the modal is up, and on a six second timer as well, so a request that never answers cannot leave a row spinning for ever. And the reordering was not only slow to look at, it was wrong. It added or subtracted a fixed amount: with the seeded values ten apart that usually landed right, and sometimes landed on a value another template already had — or on the floor along with three others. Equal sort values fall back to the name, so a click could reorder something else, or nothing, which is indistinguishable from a click that never registered. It swaps with the neighbour now: exactly one place, or nothing at the ends, both of which are what an operator expects to see. Where two shared a value the swap leaves them distinct, or the next click would do nothing again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
559a466630 |
Show that the update is running, and stop showing the rsync log
tests / pest (push) Failing after 12m32s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The maintenance screen answered a question nobody was asking. Under the headline sat a card with the deployment's raw output in it — rsync listing every font file it had copied — and, before a step was known, a box drawn around the words "Wird jetzt gestartet." A person waiting on this page wants one thing settled: is anything happening, or am I looking at a dead page. So: an indeterminate bar, in CSS. Indeterminate because a deployment has no honest percentage and a bar that claims one always stalls at 90 %; in CSS because this page is served while the application is down, so it may not depend on JavaScript having loaded or on the server answering another request. Motion off gets a steady bar rather than none — the point is to show the page is alive, and a still bar says that better than a gap. Shown to everybody, not only in the console: a customer on the 503 is asking exactly the same question. What is left of the operator detail is the current step, as one quiet line, because a run that stops on a step is worth seeing without a shell. The elapsed time and the log are gone from the overlay entirely — with them goes a file read on EVERY console page load, running or not. The settings page still shows the log, where somebody is actually looking for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
82f95df04c |
Stop the 503 page appearing during an update
tests / pest (push) Failing after 8m3s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Reported with the network panel open: `update` and `state` both answering 503, then the overlay, then Laravel's 503 page, then a reload. Three different screens for one event. Livewire's default on a failed request is to render the response body. So a wire:poll that happened to fire while the application was in maintenance mode swapped the whole console for the 503 page — the panel the operator was watching, replaced by an error page, for something that is not an error. A deployment IS maintenance mode; that is the event, not a fault. Three changes, and they are three because the request came from three places: The status endpoint is exempt from maintenance mode. It is the deployment's own status, read by the overlay every three seconds, and behind maintenance mode it answered 503 for the entire run — so the overlay could never name the step it was on. Safe to exempt: version, commit and a step name, behind the console's host and network guards, and it writes nothing. Livewire swallows a 503 instead of rendering it. For every request, not only the poll: any action taken in that window did the same thing. And the settings card stops polling for the duration. The overlay's own watcher is a plain fetch with no component state behind it, which is why it survives the restart; this poll cannot, and every attempt was a request that answered 503. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a4626d2569 |
Stop root workers breaking every page, and let the panel be closed
tests / pest (push) Failing after 12m38s
Details
tests / assets (push) Successful in 26s
Details
tests / release (push) Has been skipped
Details
── The 500 ───────────────────────────────────────────────────────────────── "touch(): Utime failed: Operation not permitted", from BladeCompiler, on every request for the affected view. Blade compiles a view and then touch()es the compiled file to the source's mtime. touch() with an explicit time needs ownership of the file. A compose service without `user:` runs as ROOT — and queue, scheduler and reverb had none — so the first worker to render a view wrote a root-owned compiled file that the web process, as www-data, could never refresh again. The queue is what renders mails, which is why this surfaced now. queue, reverb and scheduler run as www-data. queue-provisioning stays root and says why: it brings wg0 up and runs `wg set` for every peer change, which needs NET_ADMIN on the running process. It renders no mail. And update.sh normalises ownership at the END of a run as well as at the start. The first call heals what a previous run left; the second heals what this one made — `git checkout` rewrites the tree as the service account while the old build is still serving. ── The panel that would not go away ───────────────────────────────────────── The same 500 is why it kept coming back: every poll failed, the watcher treats a failed request as "still restarting" (which it normally is), and the overlay stayed up over a console nobody could then reach to find out why. A restart is seconds. After two minutes of nothing the panel now says so and offers a way out — for an operator only, behind the same flag as the step and the log, so a customer on the 503 page is not shown a button suggesting they can call the deployment off. A successful answer clears it again: one bad minute must not leave a "something is wrong" notice sitting there for the rest of the run. Not an always-present close button. The deployment does not stop because somebody dismissed a panel, and offering that at the wrong moment is a lie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ea643b5e73 |
Prove a custom domain before serving it, and keep proving it
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two things. ── The update screen, still opening twice ─────────────────────────────────── Reported again on 1.3.9, and the cause was not the one fixed in 1.3.8. 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 writes `state: running` only 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: overlay on the click, gone a poll later, 503 after it. The overlay now closes only once the server has BOTH confirmed a run and then stopped reporting it. Before the confirmation, silence means the agent has not got there yet. Bounded at twenty polls so a request the agent refuses does not leave the console covered forever. ── Custom domains, proven and re-proven ───────────────────────────────────── `custom_domain` was a free-text field and everything downstream believed it: the proxy served it, the certificate was issued for it, Nextcloud trusted it. Anyone who pointed any hostname at the platform got somebody else's files under their own name. The proof is a TXT record at _clupilot-challenge.<domain> holding a token only this instance has. Nothing is served until it has been read. Every reader now goes through Instance::address(), which is the one place the decision is made — `custom_domain ?: subdomain` was the hole, written out four times. It is re-read every night at 03:40, because a token checked once can be taken straight back out and a domain that later lapses keeps resolving here. Three consecutive misses before a live domain is withdrawn: one failed lookup is a nameserver having a bad minute, and withdrawing takes a working Nextcloud off its own address. The domain and its token stay on the row so the customer can put the record back rather than start over. Changing the domain mints a NEW token. Reusing it would let somebody who once verified example.com claim any other domain later without touching its DNS — the old record is still sitting there and only the value is compared. The domain is changeable at any time, and removable. Fixing it once set was considered and rejected: adding or moving an address is a proxy entry, a certificate and one line in trusted_domains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
07634c8a1d |
Answer a check at once, start an update at once, and count down to neither
Checking for updates was a request written into the same mailbox as a real update, collected by the same agent on the same systemd timer — so asking what was new was answered on the next tick, and the console, having nothing else to show, counted down to it. An operator who asked a question was told an update would start in 3:44. A systemd path unit now wakes the agent the moment the request file is written, for a check and for a run alike. PathChanged, not PathExists: PathExists re-arms as soon as the service goes inactive, so a request the agent could not consume — it holds a lock for the length of an update — would restart it in a tight loop. The timer stays as the fallback, and a drop-in disables systemd's start rate limit, because two triggers on one oneshot service can otherwise wedge the unit and leave neither of them able to run. The countdown is gone from both the settings card and the maintenance overlay. It could not be made honest: its target was recomputed on every poll, and where the interval the agent reports is shorter than the timer actually installed on that host it had already gone by, so the fallback moved it forward again each time. It ran backwards four seconds and snapped back to a full minute, forever — reported exactly that way. A queued run says it is starting; a queued check says nothing beyond its badge, because a check starts nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
64efe45f0d |
Recover silently from an expired session, and show a connection banner when offline
tests / pest (push) Failing after 7m22s
Details
tests / assets (push) Successful in 24s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f05547921d | Separate checking for updates from applying them, and show live progress | |
|
|
c2681f2801 |
Show a full-screen overlay on every console page while an update runs
tests / pest (push) Failing after 7m27s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9b8d5dfd1e |
Editing in modals, an update button that is not gated on a stale reading, and a support page that is real
tests / pest (push) Successful in 7m18s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 3s
Details
Three things reported together. ── Editing belongs in a modal (R20) The seats table grew its input fields into the row. It worked and it looked broken: the row grew, the columns beside it jumped, and a table half in edit mode reads as a rendering fault rather than as a form. The project already had the answer — EditDatacenter, whose own header comment says it avoids exactly that row-height jump — and the seats table simply did not use it. EditSeat is now a ModalComponent. A modal is reachable WITHOUT the page's route middleware, so it resolves the customer itself and re-reads the record rather than trusting a hydrated property: a forged addressEditable would otherwise open the address of an accepted seat, and the address is the person — editing it hands one employee's access to another with nobody told. Only an invitation still in flight can have its address corrected, which is the case that actually comes up. The actions column is now always drawn. It used to disappear when the only seat was the owner, on the reasoning that there was nothing to act on. But every seat can be renamed, and a column that vanishes does not read as "not applicable here" — it reads as "this product cannot do that", which is how it was reported, three times. The owner's row says "Geschützt" rather than leaving an empty cell. Also caught here: the edit fields carried class-wide #[Validate] attributes, so an empty edit form made the INVITE button fail on a field the invite form does not have. Rules for an action belong to the action. ── The update button "I cannot run an update, it says everything is current." `behind` is a READING taken by the agent every five minutes, not the state of the world — push a commit and the console insists it is up to date and refuses to act. The agent does its own fetch before deciding, so asking against a stale reading costs one fetch and finds nothing; being locked out costs the deployment. The button is now offered whenever an agent is alive and no run is in flight, labelled for what it does rather than for what the last reading said. ── The update never announced that it had finished Because the thing being watched restarts the thing doing the watching. Mid-run every wire:poll request fails, and what answers afterwards is a new build being questioned by the old page's JavaScript — so the card sat on "läuft" until somebody reloaded by hand. A small Alpine watcher now asks a plain JSON endpoint (no Livewire, no component state, no assets), treats a failed request as the restart rather than as a fault worth giving up over, and reloads once the build it is looking at is no longer the build it started with. ── Support The page was a decorated placeholder: a button that raised a toast saying the form was "only hinted at in the prototype", three invented ticket titles living in the translation file, and no way to see what became of anything. It looked thin because nothing on it was real. It now leads with the customer's own requests — what somebody arriving here wants to know is what they asked and whether anyone answered — with contact details moved to the side where they belong. The form attaches the plan, the instance and who is asking automatically: making a customer describe their own server back to the people who built it is the part of support people hate. FAQ answers end where the thing can actually be done, and "is it me or is it you" is answered by a link to the status page. 637 tests. R20 recorded in CLAUDE.md and enforced by EditInModalTest: no page view may grow an input field inside a <td>. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8117630a65 |
feat(ops): update page, automatic 419 recovery, CI workflow
Three things that bit us on every deploy: - artisan down now renders a branded page that says an update is running and reloads itself, instead of Laravel's bare 503. - A page left open across a deploy carries a Livewire snapshot and CSRF token the new code rejects — the user got "419 Page Expired" and a dead interface. A 419 from a /livewire/ request now reloads the page; the session is still valid, so that is all it takes. Hooked at the fetch layer rather than through Livewire's request hook, whose failure callback is not invoked for this case in the installed version — verified against a real 419 in the browser, not a simulated one. - Vite no longer empties its output directory: wiping it is what left an open page without its stylesheet mid-deploy, which is the "design is completely broken" symptom. update.sh also rebuilds the caches with optimize:clear + optimize instead of leaving them half-warm. Plus CI: .gitea/workflows/tests.yml (Pest + asset build, and a tested- tag only on a green main) and an opt-in act_runner service under the ci profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
0d9b62eb50 |
feat(vpn): ownership, a Developer role, and password-gated config retrieval
Reworked after a design consultation with Codex, which pushed back on my first proposal in three useful ways. Ownership and rights: - vpn.manage split into vpn.view.all and vpn.manage.all. Seeing an access is not managing it, and neither is holding its private key. - Record-level rules live in VpnPeerPolicy, not in permissions: an access belongs to a person, and ownership is what grants sight of it. Every operator reaches the page, but sees only their own unless they may see all. - Issuing an access is not self-service — it reaches the management network, so it needs vpn.manage.all even for oneself. - New Developer role: sees everything, manages nothing. Writing code does not imply authority over other people's access. - kind (staff|host|system) replaces a null user_id that had to mean two different things at once. Config storage, opt-in per access: - Downloading is owner-only — explicitly NOT for view.all or manage.all. An admin who needs access revokes this one and issues their own, which keeps the record of who holds what honest. - The password is asked on EVERY retrieval, rate-limited. Laravel's password.confirm keeps a 15-minute session stamp, which would authorise unlimited later downloads from an unattended browser. - Stored under VPN_CONFIG_KEY, not APP_KEY: a leaked application key must not also hand over the management network. Purged on revocation and when a staff member is revoked — console taken away, tunnel left open was the worst case. - The plaintext never enters a Livewire snapshot (the page polls every five seconds); the component carries an opaque handle instead. Also: copy button works without HTTPS again (navigator.clipboard only exists in a secure context, so over http it silently did nothing), plus config download as a file and a QR code for the mobile apps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
e3ab3cb501 |
feat(portal): service framing + gradient line charts + billing button fix
- plans gain seats + performance class; customer views show storage/seats/ performance/features, no raw vCPU/RAM (admin keeps specs) - billing: scoped wire:target + wire:key so one purchase no longer spins all - chart island: line-colour->transparent gradient for filled line datasets; MRR / backups / invoices bar charts converted to gradient line Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
1aa7535fc4 |
feat(admin): dark Tactical-Terminal operator console
Separate admin console at /admin, gated to is_admin users (EnsureAdmin middleware + migration/seed; a plain customer user is seeded to prove the gate). Distinct dark aesthetic achieved purely by token scoping: .theme-admin overrides every CSS design token to a dark graphite / signal-orange palette, so ALL shared components (button, card, badge, table, stat, chart) render dark with zero new markup (R3). Chart island now reads tokens from its own element, so charts are theme-aware. Sections (each full-page class-based Livewire, English routes R13, localized DE/EN): - Overview: fleet KPIs, fleet-growth line, host-load bars, MRR bars, active runs, alerts. - Customers: table + plan doughnut. Instances: fleet table (vmid/host/storage). - Hosts: capacity cards (storage/CPU bars). Provisioning: runs table + live stepper. - Revenue: MRR/ARR/ARPU/churn KPIs, MRR line, plan doughnut, recent payments. - Locale-aware month labels/currency (Carbon/Number). 18 new Pest tests (guest redirect / non-admin 403 / admin render per section) → 44 green. R12 browser: all six admin pages HTTP 200, ZERO console errors (Chart.js dark). Codex (R15) — clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
499adaff00 |
feat(dashboard): rich overview with Chart.js (customer-portal template)
- Chart.js as an Alpine island (x-ui.chart): configs are PHP arrays; colours use token: strings resolved from CSS design tokens at runtime (R3). Respects reduced-motion; IBM Plex chart defaults. - Tokens: soft --radius-xl (20px), --success-bright for dots/rings/charts. Staggered 'rise' reveal keyframe. Global toast in the app shell. - Overview rebuilt to the customer template: storage doughnut ring, availability sparkline, KPI tiles, upsell, cloud card, interactive onboarding checklist (Alpine), backups, modules (Lucide icons — no emoji, R9), activity feed. - Locale-aware fixture display: Carbon isoFormat dates + Number::format sizes so the EN dashboard is not mixed-language (R16). - 14 Pest tests green; R12 browser: /dashboard 0 console errors with Chart.js; R7 no overflow at 375/768/1280. Reviewed with Codex (R15) — clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
254a7d46a0 |
feat(portal): Fortify auth + Login/2FA/Dashboard + component kit
Backend (Fortify): - laravel/fortify with TOTP two-factor + recovery codes; User uses TwoFactorAuthenticatable; 2FA credentials hidden from serialization. - Views off — pages are full-page class-based Livewire components (R1/R2); Fortify handles POST actions. Home redirect -> /dashboard. v1 scope: login + 2FA only (no public register/reset/passkeys). Seeder gated to local/testing. Component kit (Blade, token-based, a11y): - button, input, checkbox, alert, card, badge, stat-tile, otp-input (Alpine, auto-advance/paste, -safe submit), progress-stepper, nav-item, icon (Lucide), plus layouts/portal-app app-shell (sidebar drawer + topbar + menu). Screens (localized DE/EN, R16): - Login (form -> login.store), Two-factor challenge (OTP + recovery fallback), Dashboard (KPI stat tiles, instance card, provisioning stepper fixtures, activity). Routes English (R13). Tests + verification: - Pest: 14 green (login ok/invalid/throttle, dashboard guard, component render). - R12 browser (Puppeteer, prod assets): /, /login, /two-factor-challenge and the authenticated /dashboard all HTTP 200 with ZERO console errors; login flow verified end-to-end. - Test isolation fixed (force test env over injected .env). - Reviewed with Codex (R15): 4 rounds, all findings fixed, final pass clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
2ae7595aff |
chore: bootstrap CluPilot control-plane (Laravel 13, Docker stack)
- Docker dev stack: app (php-fpm+nginx+vite via supervisor), reverb, queue, scheduler, mariadb 11.4, redis 7 — env-driven ports/UID (HOST_UID=1000). - Laravel 13.8 + Livewire 3.8 (pinned v3 per R2), Tailwind v4, Reverb, Echo, phpseclib, wire-elements/modal. - .env wired to service names; DE default locale (R16); browser Reverb host env-driven; self-hosted-fonts-ready vite.config (R14). - Entrypoint bootstraps vendor/node_modules on fresh checkout; bin/clupilot helper (UID/port env-driven) + shell aliases. - Reviewed with Codex (R15): 7 findings fixed, 1 verified false-positive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |