Commit Graph

16 Commits (37684337923690c80925642d4ca28a446e1f0d2d)

Author SHA1 Message Date
nexxo 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>
2026-07-29 20:33:02 +02:00
nexxo 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>
2026-07-29 17:50:15 +02:00
nexxo 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>
2026-07-29 15:43:54 +02:00
nexxo 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>
2026-07-29 14:47:51 +02:00
nexxo b843c10ec3 Give the update screen the product's colours, and only one window
tests / pest (push) Failing after 8m16s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Two complaints, both correct, both mine.

Dark blue. The panel carried a `prefers-color-scheme: dark` block — the only
one in a product that has no dark mode. On a phone set to dark the update
screen, and nothing else, turned dark blue. A dark mode for one page is not a
dark mode; it is one page that does not match. It is gone.

And even in daylight the panel was not this product's colours: it used
Tailwind's slate ramp (#0f172a, #64748b, #e2e8f0), which is blue-tinted,
against neutrals that are warm. Every value is now a copy of a token from
portal-tokens.css, with the comment saying so, because this file cannot
import them — it renders when the asset build may not exist.

The small window then the big one. The settings card grew its own little
"läuft gerade" block the instant a run started; up to three seconds later the
full-page overlay covered it and said the same thing in a different size. The
card no longer says it at all, and the overlay now opens on the button's own
click — requestUpdate dispatches an event, but only once a run is actually
pending, so a refused second press does not black out the console.

Two more things found while looking:

The panel had `min-height: 100%`, which resolves against the parent's height.
On the 503 page the parent is <body>, whose height is auto — so it computed
to zero, the panel was only as tall as its text, and the bottom two thirds of
the screen stayed the browser's default white. Viewport units now, dvh so a
phone's collapsing address bar leaves no strip either.

And the mark was a plain orange tile again, the same empty-square that was
just fixed on the placeholder. The real logo is inlined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:11:01 +02:00
nexxo 0671e8e119 Show one design while updating, not two swapping mid-run
Reported from live: pressing update showed a blue panel first, which then
vanished and was replaced by a completely different "we are updating"
screen. Both were mine. The console drew its own overlay in the app's light
tokens; a moment later the containers went down and Laravel's 503 page took
over with its own standalone styling. One event, two faces, swapping while
an operator watches.

There is now one panel — resources/views/partials/updating-panel.blade.php —
rendered by both. It carries its own inline CSS and cpu- prefixed class
names because the 503 page is served with the application down: no compiled
stylesheet, no Tailwind, nothing but what is in that file. Dark mode comes
from prefers-color-scheme for the same reason.

The operator detail (step, running since, log tail) rides inside the panel
behind a flag, since the same file is shown to customers on the 503 page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:20:40 +02:00
nexxo 21b7aaca42 Update only to a released version, never to whatever landed on main
tests / pest (push) Failing after 7m21s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Asked for directly: a commit on main must change nothing on a server, and only
a tag whose version is higher than the installed one may be installed.

The agent asked two different questions before this. A pinned server compared
tags; everything else counted commits on its branch — so a console following
main offered an update for every push, verified or not, and "Jetzt
aktualisieren" meant "take whatever is on the branch right now". Landing on main
and being released are different events, and only the second is a decision
somebody made.

It is one question now, regardless of what the checkout is attached to: is there
a tag whose version is higher than the deployed one? Compared with sort -V
against the version in the deployment manifest — not the VERSION file, which an
update moves before it migrates, so a run that failed in between would leave the
checkout claiming a version it never finished installing. A run always targets
that tag; the branch path is gone. A request that arrives with nothing to
install is answered rather than obeyed, before anything announces a run.

The console follows: the button is disabled with nothing released, says so in a
line rather than leaving a grey button to be guessed at, names the version it
would install rather than a count, and refuses the action server-side as well —
a Livewire action is a public endpoint, and one place a rule may not live is
only in the disabled attribute of a button.

VERSION becomes 1.1.0, to be tagged as the first release this mechanism can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:32:44 +02:00
nexxo 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>
2026-07-28 22:25:07 +02:00
nexxo f05547921d Separate checking for updates from applying them, and show live progress
tests / pest (push) Failing after 7m11s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-28 20:23:06 +02:00
nexxo 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>
2026-07-28 18:50:45 +02:00
nexxo 87c49de6e5 Move the console's identity out of the customer table 2026-07-28 10:31:43 +02:00
nexxo 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>
2026-07-27 17:55:49 +02:00
nexxo bd779f00e5 Show times on the operator's clock, keep storing them in UTC
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 30s Details
tests / release (push) Successful in 6s Details
The console announced an update for "spätestens um 15:21" while the clock on
the wall said 17:21. Not a formatting slip in one string: fourteen views and
four components printed database values straight out, so every absolute time in
the product was two hours out all summer and one hour out all winter.

Two of those views looked as though they had handled it. They called

    ->timezone(config('app.timezone'))

which reads like "convert to local time" and, app.timezone being the STORAGE
zone that must stay UTC, converts to UTC — a no-op wearing the costume of a
fix, which is worse than no call at all because it stops the next person
looking. That exact string is now banned by test.

Worse than the labels were two FORMS. Maintenance windows and plan versions
filled their datetime-local fields from UTC and parsed what came back as UTC. A
datetime-local field carries no offset — it is the digits a person reads off
their own clock — so an operator typing 21:00 scheduled a window for 23:00
their time, and nothing anywhere said so. App\Support\LocalTime now holds
toField() and fromField() side by side, because getting one end right is not
half a fix, it is a fresh bug.

The mechanism is one Carbon macro, ->local(). It copies before converting:
Illuminate\Support\Carbon is mutable, so without that, rendering a timestamp
would rewrite the model attribute as a side effect and anything comparing or
saving it afterwards would be an hour or two out. Both Carbon classes get the
same body — Carbon keeps macros in one global table, so the second registration
replaces the first for every Carbon class, and two different bodies meant the
immutable version, safe for itself, silently became the mutable one's
implementation too. My own test caught that.

The existing tests had not caught any of this because they built their expected
values with the same wrong call the views used — a test that recomputes the
implementation asserts nothing. They now assert the wall clock, and the queued
update additionally asserts that the UTC time is NOT shown.

Recorded as R19 in CLAUDE.md and enforced by tests/Feature/DisplayTimezoneTest:
no Blade or Livewire component may format an absolute time without ->local(),
the UTC no-op is banned, storage stays UTC, both sides of the DST boundary are
checked, ->local() must not mutate, and a form field must round-trip to the
same instant.

APP_DISPLAY_TIMEZONE, default Europe/Vienna. 615 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:32:21 +02:00
nexxo 3699c7cdb9 Ask the tunnel gateway where it actually listens
tests / pest (push) Successful in 7m15s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 5s Details
The console has been unreachable over the VPN, and the cause was the readiness
probe rather than the tunnel. The gateway binds to the WireGuard hub address
alone; the probe asked 127.0.0.1, where nothing has ever listened. It could only
fail, so VPN_READY stayed false, and on that basis the application withheld the
`DNS = 10.66.0.1` line from every client config it issued. A phone then built
the tunnel, asked its normal resolver for the console hostname, got the public
address and had its connection closed — indistinguishable from "this site does
not exist", which is exactly what it looked like.

Probing over TLS on the bare address would not have worked either: the site
matches on the console's hostname, so a request without SNI is offered no
certificate. The gateway now answers a plain-HTTP health port on the hub
address, which removes TLS, SNI and name resolution from the question and
answers only what is being asked — is this gateway listening, in this network
namespace, right now. Caddy refuses to start when the certificate is unreadable,
so a health port that answers still proves the whole file loaded.

Also here, all found while looking:

- Icons pushed their label onto a second line and rendered a size larger than
  asked for. Tailwind's preflight makes an svg display:block, and `.size-4` and
  `.size-5` have equal specificity, so stylesheet order decided — and it emits
  size-4 first. Every icon written as 16px was silently 20px. Recorded as R18.
- Four error pages printed `errors.404.hint` in place of a sentence: the lang
  files give those codes a null hint and Laravel returns the key for a null line.
- The Developer role had no label in either language, so the dropdown showed
  `admin_settings.role_developer`.
- The secrets area held one key, which is not worth a password gate. It now
  carries the credentials that actually stop the business when they expire —
  DNS, monitoring, SMTP — and the test button appears only where a checker
  exists, instead of reporting on Stripe whatever was being looked at.
- The update button said nothing about when a queued run would start or where a
  running one had got to; both are now shown, and a failure names its step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:43:23 +02:00
Claude 935c9ae6ac Let people change their own password, and a location be switched off while editing it
tests / pest (push) Successful in 7m29s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
Three gaps, all of the "half-built" kind.

Nobody could change a password. Fortify's updatePasswords feature was switched
off, so an account created with a generated password kept it until someone
opened a shell on the server — including the owner's own. Both settings pages
have the form now, sharing one concern, because two copies of a password rule is
how one of them ends up weaker. It goes through Fortify's own action rather than
hashing here, and the current password is required: an unlocked machine should
not be two keystrokes away from locking its owner out.

The datacenter edit form had no active switch. The column and the scope existed,
and the list page has a toggle — but the form you open to change the thing did
not offer the one lifecycle action a location actually needs. Switching one off
now says what it does NOT do: existing hosts keep running. Once, on the actual
transition, and as the only message — the generic "saved" toast would otherwise
replace it, and an already-inactive location would have announced its own
deactivation every time its name was edited.

The staff table's actions cell is empty for your own row, because you cannot
revoke yourself. With a one-person team the column was therefore always blank
and read as broken rather than as a rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:49:07 +02:00
Claude 276f926d57 Add the update button, and give it a host-side agent that can actually update
tests / pest (push) Successful in 7m41s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
The panel had no way to update the installation, and could not have one on its
own: it runs as www-data inside a container, and the update restarts that
container. A button that shelled out would kill its own web server
mid-response, and would need the application to hold host-level credentials —
a far worse thing to own than an out-of-date checkout.

So the button is a mailbox. The panel writes a request into the checkout; a
systemd timer on the host, running as the service account, consumes it, runs
deploy/update.sh and writes back what happened. The same timer answers the
question the panel cannot answer alone — is there anything to update — which
needs a git fetch, and therefore credentials the application does not have.

The parts that were wrong before review, all of which would have shipped as a
button that looks fine and does nothing:

- The agent was only installed by install.sh, which the installed base never
  runs again. Every existing server would have shown the button permanently
  disabled. It has its own root entry point now, install.sh delegates to it,
  and update.sh says so when the unit is missing.
- On a release-pinned server the checkout is detached, so it followed
  "origin/HEAD" and then ran an update that deliberately stays on the pinned
  tag: updates advertised, nothing applied. It now compares TAGS in release
  mode and passes the target release through.
- On a branch other than main it advertised that branch's commits and then
  deployed main's, because update.sh defaults to main.
- A request written while the agent was down was executed whenever the agent
  next started, days later.
- A single status file meant the routine five-minute check overwrote a failed
  update with "idle" before anyone saw it.
- An agent that had been stopped left the button enabled forever, because a
  status file written once counted as an agent.
- The agent's error messages were German strings rendered into an English
  interface; it reports codes now and the panel translates them.

Also: the add-address button in the console-access panel was as tall as the
field plus its hint, because the hint was a sibling inside the flex row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:33:17 +02:00