23 Commits (880b5f19982d51c9ecf42fdaf3b2e51fe5163643)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
8479fc30f8 |
Fix the checkout link, and stop the switch moving under the cursor
**Every package button led to "Dieses Paket steht nicht (mehr) zum Verkauf".** The link was built as route() plus "?term=" + term — but both are QUERY parameters on this route, so route() had already opened the query string and the result was `?plan=team?term=monthly`. The package arrived as the whole string "team?term=monthly", matched nothing in the catalogue, and the page said so politely while giving no hint why. The address now comes from route() in one piece with a placeholder swapped into it, and a test asserts every checkout link on the page contains exactly one question mark. **The saving moved the buttons it belonged to.** It hung under the switch and was hidden on the yearly view, so choosing a term shifted the very control that chooses it. It sits above now and never disappears: one line, two wordings — an offer while it is an offer, a statement once it is taken. Same on the public sheet, where its vanishing re-centred the row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ff92466b47 |
Give the order a checkout page, and stop hiding what a year costs
**There was no checkout page.** The package list posted straight to Stripe, so the last thing a customer saw in this product's design was a grid of cards and the next was somebody else's form asking for a card number. What they were buying, what it costs in total, what happens afterwards and what they were agreeing to were never stated in one place. App\Livewire\Checkout is that place: the package and its figures on the left, a sticky summary on the right — net, the saving where there is one, the setup fee, the tax, what is due today, and what will be charged after it. Every figure comes from the catalogue, TaxTreatment and SetupFee, the same three the checkout itself reads; nothing is recomputed, because a summary that works out its own total is a second answer waiting to disagree with the one Stripe is handed. **The terms acceptance moved to where the money is spent.** It sat at the top of the package list as a condition of LOOKING at prices, with every button dead beneath it. It is a condition of buying, so it is now at the foot of the summary, directly above the button — after the total, which is the order those three things are read in. The package list has no form on it at all now; its buttons are links. **The page used the left half of a 1240px shell.** A 68-character title, a switch under it and an 80-character footnote, beside a grid that used the whole width. Now: a header band with the name left and the term switch right, where a control belongs, and a closing band of three columns instead of a paragraph trailing off. **The yearly view showed the monthly equivalent as its headline**, so on a package with no free months the big figure did not move at all when the switch was thrown — the page looked like it had ignored the click. The headline is the price of the term in the unit it is charged in; what a year works out to per month is said underneath. Same on the public sheet. **The switch was unreadable.** `text-on-ink` is not in this palette — the ink pairing is bg-ink/text-bg, as x-ui.button has always had it — so the label kept its inherited grey on a near-black pill. And its shell was rounded-lg with rounded-md options inside: two radius scales, one of them Tailwind's untokenised 6px. One scale now, nested. **The mails drew an empty orange tile.** The mark's ascent lives in an SVG the site loads and a mail cannot — Gmail strips inline SVG, Outlook renders with Word, and this layout carries no image by design. The shape is drawn with a character every mail font has, white on the accent tile, with the whole wordmark beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b32c6fc33f |
Sell the year as well as the month, and say what it saves
Both prices have existed since the catalogue did — one `plan_prices` row
per term, each with its own Stripe Price, and the checkout has taken a
`term` all along. Nothing ever sent one. The shop showed the monthly
figure, the order page posted no term, and the yearly Price sat there
being paid for by nobody.
**The console asks for free months, not a second price.** It used to take
the yearly TOTAL as its own free-form figure, so nothing in the system
knew WHY 588 belonged to a package costing 49 a month — and no page could
say "zwei Monate gratis" without a person working it out again and writing
it somewhere it would then drift. Now: the monthly price and how many of
the twelve months are free. The total is derived from the two
(PlanVersions::yearlyCents), previewed live on the form, and written to
the catalogue from the same arithmetic.
`free_months` sits on the VERSION, beside the capabilities publication
freezes, because that is what it is — part of the terms a customer bought.
The migration reads it back out of the prices that already exist rather
than defaulting to nought: an installation selling twelve-for-ten must not
lose that on a deploy. Only an exact division counts; a hand-negotiated
yearly figure keeps its amount and simply goes unexplained.
**The customer chooses.** A switch on the order page and on the public
sheet, both figures rendered and one shown, so switching costs no request.
The headline stays "per month" in either term — a yearly total as one big
number reads as five times dearer at a glance — with the amount actually
taken, the net figure, the months free and what twelve monthly payments
would have cost underneath. The form posts the term the customer was
looking at, and the checkout picks that term's Stripe Price.
The billing card now says which term is running. It states a monthly
figure whatever the term, so a yearly customer was reading a number that
never appears on a statement.
Also: **a Blade comment must never spell a directive.** Blade compiles
what is inside `{{-- --}}` too — the comment is stripped from the output,
but its directives are compiled on the way there. A comment explaining why
the parenthesised php directive had been avoided produced exactly the open
PHP tag it warned about, and the rest of the file was swallowed as PHP
source: no exception, no warning, the page one block short. That is how
the term switch vanished after being written. BladeCommentsTest scans for
the forms that open a tag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
6b8412f0c3 |
Write an invoice for work that came off no price list
tests / pest (push) Failing after 8m11s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Not everything we are paid for is a package. Somebody asks whether their data can be moved into Nextcloud; we look at it, we say what it costs, we do it. That is a real invoice with no order and no contract behind it, and the only way to produce one was to write it somewhere else — which puts it outside the series. A numbered series with a document missing from it is worth nothing at an audit. So it goes through the same door as every other invoice: IssueInvoice::forService() draws from the same series and therefore the same consecutive number, applies the same TaxTreatment (a verified business in another member state gets its reverse charge and the note that makes a zero-rated document lawful), freezes the same immutable snapshot and dispatches the same archive jobs. The page under Rechnungen writes what the operator typed and nothing else. Amounts are in euro because it is a form — 12,95 becomes 1295 by round(), not by a cast that would make it 1294 — and quantities go to a thousandth because hours are billed in quarters. The total beside the lines is the same InvoiceMath the document uses; a preview doing its own arithmetic would be a second answer, and the first one anybody notices differs is on a document that cannot be changed. There is no draft: a "save as draft" that draws a number is an issued invoice with a friendlier name. The website's buy buttons go to the sign-in now, not to /order. Booking belongs in the panel, where the account exists and its address has been confirmed — which matters because that address is where the finished cloud's credentials are sent. Sending an anonymous visitor at a page behind auth worked, but only via a middleware bounce off a page they never asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f85f57152e |
Let the customer buy it themselves, and tab the integrations page
tests / pest (push) Failing after 21m20s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Every path on the site ended in "anfragen": a mailto: link and a promise to answer the same working day. That is an afternoon of somebody's time per customer, for a product whose whole point is that the machine does the work — and it scales exactly as far as one inbox does. Now: sign in, pick a package, pay, and provisioning starts by itself. The pieces were already there — Stripe holds a Price per plan version (the catalogue sync writes the ids), the webhook opens the contract, the pipeline builds the VM — and what was missing was the checkout session between them. CheckoutController opens one and gets out of the way; the purchase becomes real on the webhook, which stays the only place allowed to believe a payment happened. The success URL is a redirect target and therefore a URL anybody can type, so it says "we are building it", not "you have paid". Nothing in the request may decide the price. The plan key arrives from a form and everything else is re-derived from the catalogue, including the plan VERSION, so a version published while the customer types their card number cannot change what they were quoted. Two cases that are not errors: no capacity is a parked order (already built), and an existing customer is sent to the plan change rather than sold a second, empty cloud that bills twice. The site says what it can keep. "danach wissen Sie … wie Ihre Daten sicher umziehen" was a promise made before seeing the thing it is about: we do not know where a visitor's data is, what shape it is in, or whether it can be moved at all. Migration is named as its own question, looked at first and quoted afterwards. The integrations page gets the settings page's treatment for the same reason: it was one narrow column of six cards, so an operator connecting DNS scrolled past Stripe to reach it and the raw .env editor sat at the bottom of everything. Three tabs by what a section is FOR — services we buy from outside, our own machines, the file underneath — the same axis the page was rebuilt around when it stopped being split by storage mechanism. The tab is in the query string; unlocking survives a switch, because a confirmed password is a session fact and not a property of a tab. The .env tab is Owner-only, like the editor it holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
7265799881 |
Quote what a person pays, and tab the settings page
tests / pest (push) Failing after 7m50s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Prices. The sheet quoted net, which is the wrong number for one of its two readers: a private customer has no way to add 20 % in their head and no business being surprised by it at checkout. The gross figure is now the big one, with the net underneath for a company's books, and the same rule applies to the module prices further down — a sheet that quotes the package with VAT and the module without it is a sheet whose numbers cannot be added up. The rate is the one on the Finance page, the same one an invoice uses; a percentage written into the template would be the second source that makes the two disagree. "zzgl. einmaliger Einrichtung" had run for months without ever naming a figure, which leaves a visitor knowing only that there is one. It is a field on the Finance page now and prints with the price. Zero removes the sentence — and the comparison card that listed the fee as the honest downside of the offer — rather than admitting to a cost that is not charged. Layout. Four packages in 1120px left each card 202px of text, which is what broke "Bereitstellung in 2-3 Werktagen" over two lines and made the cards look pressed against each other. The pricing section is wider than the rest of the page (only it: the reading columns elsewhere are narrow on purpose, a price sheet is four columns that must be comparable at a glance), the gap between cards is larger, and the delivery line is short enough to fit with whitespace-nowrap to keep it that way. Navigation. VPN moves from Betrieb to System: the tunnel is not a task of the day, it is how the console reaches the estate at all. Settings. The page had grown to three headed sections in one column with the operator's own password somewhere in the middle. It is tabbed now, and two-factor enrolment is one of the tabs instead of a menu entry of its own — it is a setting, and it was the one thing an operator had to leave this page to change about their own access. Same component, no second copy of the enrolment logic; it still answers at its own route because RequireOperatorTwoFactor sends an operator there who may not open anything else yet, this page included. The open tab lives in the query string, so a reload, a bookmark and the back button all land where the operator was. That matters here more than elsewhere: the installation tab refreshes itself while an update runs, and a tab kept only in the component would drop them back to the first one mid-deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ec8675861e |
Take the order, park it, and say when it will be delivered
tests / pest (push) Failing after 7m44s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A paid order that no host has room for used to FAIL at the reservation
step. The customer has paid by then, so that turned a sale into an
incident and left somebody holding money against nothing. The estate is
finite and booked thick, so "no host has room right now" is an ordinary
Tuesday — it means a machine has to be bought, which takes days.
So the order waits instead. poll(), not retry(): the wait is measured in
days and must not eat the run's attempt budget. It only fails after
PARK_DAYS (14) — long enough to buy, rack and onboard a server over a
weekend, short enough that a forgotten order surfaces instead of waiting
for ever.
Then say so, in the same words on both sides of the payment:
- The price sheet marks each package "wird sofort ausgeliefert" or
"Bereitstellung in 2-3 Werktagen", read from free capacity rather
than written into the page.
- The customer's own overview says their cloud is being prepared while
it is parked, instead of a stepper standing still for two days under
"wird eingerichtet" — which reads as broken.
- The console front page shows the same per-package answer, so an
operator can see what visitors are being promised.
And a capacity page for the decision that follows: who is waiting, what
the roomiest host still has, what the queue needs ("3x Start, 1x
Business" - the LARGEST parked package sets the minimum machine, because
an instance lives on one host), and what such a machine costs today from
the provider's live list. It orders nothing: buying a server is a
contract, and a bug in a capacity calculation must not sign one.
The operator can also say where each parked order goes. A pin is
honoured or it waits — never quietly redirected — because placing it
elsewhere answers a different question than the one they answered, and
they would find out from the finished instance.
Two things this shook out:
- `current_step` is an INDEX into the pipeline, not the step's key, and
it is cast to integer. `where('current_step', 'reserve_resources')`
reads as correct and matches step 0 of EVERY pipeline instead. Both
lookups go through HostCapacity::parkedRuns() now, which resolves the
index and filters by pipeline.
- The auction feed sends `hdd_hr` as a list, not a string. Casting it
printed "Array" into the console and raised a warning that took the
page down with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
98afd1d7e0 |
Re-apply what the last release commit silently reverted
tests / pest (push) Failing after 7m54s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
The English paths and the zone migration went out in
|
|
|
|
1620cb9395 |
Release 1.3.22
tests / pest (push) Failing after 8m15s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
v1.3.21 was taken by a parallel release between reading main and writing the commit; the version file went out saying 1.3.21 on a commit that is not that tag. Corrected here rather than by rewriting a pushed commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5f74a5f370 |
Move the zone by migration, and put every path in English
tests / pest (push) Failing after 8m8s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
── The zone, for the fourth time ────────────────────────────────────────────
"Change it in the console" was the wrong answer three times running. An
installation with nothing to migrate should simply arrive on the new zone, so
a migration does it — guarded by exactly the condition clupilot:check-zone
reports on: no instance in service, therefore nothing carrying the old name in
DNS, in a certificate or in trusted_domains. With even one instance it does
nothing and logs why, because re-addressing a running Nextcloud is a migration
with an outage and a migration is not the place to start one. It leaves an
installation already on another zone alone: the setting exists so somebody
else can run this on their own domain.
That fixes both reports at once — the security page and the price sheet read
the same setting, which is the entire reason neither of them has the domain
typed into it.
── R13, applied to all of them this time ────────────────────────────────────
"Ich schreibe das nun zum dritten Mal" — fair. The rule was cited, I changed
the one route that had been pointed at, and left every other German path
standing. A fragment is part of a URL too.
#produkt → #product /legal/impressum → /legal/imprint
#ablauf → #process /legal/datenschutz → /legal/privacy
#preise → #pricing /legal/agb → /legal/terms
#fragen → #faq (old paths redirect, permanently — they have been
#kontakt → #contact in the footer and in mail for weeks)
The headings stay German. "Impressum" is the legal term an Austrian company
has to use; the address is not part of the copy.
A test now walks every registered route and every fragment the site links to,
so the next German path fails the suite instead of being reported a fourth
time.
Three existing tests set config('provisioning.dns.zone') and read it back
through the accessor. That worked while the setting was empty and stopped the
moment the migration filled it — they set the setting now, and the one whose
premise is "nothing saved yet" establishes that premise itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
d5b2612e24 |
Always name the host we are being served from, and stop promising a duration
tests / pest (push) Failing after 8m10s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
── Why the domain list looked wrong ─────────────────────────────────────────
Reported three times, and it was not only the setting. The list came from
`admin_access.site_hosts` and the instance zone — and where SITE_HOST is not
configured, site_hosts is EMPTY. So an installation reached by a name nobody
wrote into the config listed ONLY the instance zone, and described it as the
customers' domain: the page a visitor opened to check our addresses omitted
the address they had just typed, and named the wrong thing as ours.
The host serving the request is now always in the list. It is the one address
that is true by construction and needs no configuration to be right.
(The zone itself is still one setting, and it is now clupilot.cloud on this
machine. On another installation it is Konsole → Integrationen → DNS-Zone.)
── The clock ────────────────────────────────────────────────────────────────
"Eine Zeit angeben geht nicht — wenn es fertig ist, ist es fertig." Correct,
and it was claimed three times over: an eyebrow saying "In 20 Minuten
betriebsbereit", a provisioning log with minute marks down the side, and a
closing line repeating it. A run waits on a hypervisor, on a certificate
authority and on DNS propagating. A customer who once waits forty minutes has
been told something untrue by their supplier, and the page cannot take it back.
What replaces it is what actually IS promised and kept: the order of the
steps, ticked off, numbered 1/5 to 5/5 — and then the sentence that answers
the real question behind "how long", which is not a number but "do I have to
sit here": you get an email with the address and the credentials when it is
ready.
The same claim is gone from the hero meta ("< 20 Min." → "automatisch"), from
the figure line ("20 Minuten bis zum Betrieb" → "0 Termine bis zum Start") and
from the FAQ, which now says outright that we do not name a duration and why.
Kept deliberately: the 45-minute training session and the three-to-seven
working days for setup. Those are appointments we schedule and commitments we
control, not predictions about a machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
085b110e7f |
Link the phishing page from the section people look in
tests / pest (push) Failing after 11m16s
Details
tests / assets (push) Successful in 27s
Details
tests / release (push) Has been skipped
Details
Reported: "I cannot find that under Sicherheit on the website." Fair — the page was linked from the site footer, from every mail footer and from under the sign-in form, and none of those is where somebody goes when the thought "is this really them?" occurs. They go to the section called Sicherheit. It is now the last thing in that section, as its own card: what the page is for, in one line, and where it leads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
517606b4db |
Split the price sheet into what everyone gets, what differs, and what costs extra
One boolean matrix was carrying three different kinds of statement, and it made all three unreadable. Updates, backup and monitoring were a tick in every column — a comparison that compares nothing — and the same promises were repeated in a prose line under the table. They are now stated once, above it, together with the platform address in the shape a customer will actually type (ihrefirma.<zone>, from the configured zone) rather than as the phrase "eigene Subdomain", which said nothing and was modelled as though an own domain replaced it. Provisioning issues both. Support was two independent booleans on one axis, so enterprise — which carries the SLA and not the priority flag — rendered "Bevorzugter Support —" beside team's tick: the top plan looking worse than the middle one. It is one row of levels now, and a plan with neither shows Standard, because standard support is a level and not an absence. A cell had two states, so a dash meant both "you cannot have this" and "you can have this for nine euros". There are three now, and the middle one quotes AddonCatalogue — which is also how the modules we have been selling all along finally appear on the page at all. Nothing is invented: where we sell no module for a missing feature, the cell stays a dash. custom_domain joins the module list at the owner's figure so the two plans without it can be quoted a price instead of told no. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8c105c17d6 |
Move each plan's marketing copy from the controller into the console
tests / pest (push) Failing after 8m10s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Audience line and note were LandingController::COPY, a hardcoded array keyed on exactly the four plan keys the seed migration created — a plan made under any other key rendered with no copy at all. They move onto the family for the same reason the recommendation mark does: they are a stance about the product line, not a capability that changes with a price, so they must not be frozen per version. Backfilled in the migration from the controller's own strings, so the public page prints exactly what it printed a moment ago. A family with no copy yet renders without the audience line rather than an empty gap. Feature labels (managed_updates, daily_backups, ...) stay in the controller: they name catalogue features, not individual plans, so there is no per-plan or per-version place to hang an editable customer-facing label without re-introducing a shared feature table. That table existed once and was deliberately removed when the plan catalogue closed its pricing split-brain; bringing it back for wording alone is a bigger, separate decision. |
|
|
|
012d0f5092 |
Say EU, and stop promising to fix somebody else's deleted folder
tests / pest (push) Failing after 8m13s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two corrections from a read of the live pages. The location. Three public places named countries or a city — "aus deutschen und finnischen Rechenzentren" on the placeholder, "in Österreich und Deutschland" in the FAQ, "EU · Wien" in the product panel — and they did not even agree with each other. They all say EU now, which is the thing that is promised, the thing the AV-Vertrag pins down, and the thing that stays true when a host moves. The same string in the customer dashboard was worse: 'EU · Rechenzentrum Falkenstein' was hard-coded and shown to every customer whatever host their instance actually runs on. A specific claim nobody derived from the record. The closing headline asked who you call when a folder is missing on a Monday morning. It promised the wrong thing: what happens inside a customer's own Nextcloud is their own house — the recycle bin and the version history are right there — and presenting ourselves as the first call for somebody else's user error offers a service we do not run. It now asks who looks after updates, backups and outages, which is what the comparison section already says in the operator's own words and what the product is actually for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
696c8834b8 |
Redesign the public website instead of recolouring it
tests / pest (push) Failing after 8m15s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Correctly rejected: the last pass was the old page in new paint. It kept the skeleton — eight numbered sections, a split hero, a twelve-row comparison matrix — and changed only the palette. A palette is not a design. What is actually different now: - The § numbering is gone. Numbered sections with a drawn rule are a document convention, and the document voice is exactly what the product's design system dropped. - The hero shows the product instead of describing it: centred type, then a wide panel built from the real console's own tokens, cropped by the section below. The old hero put a table of facts beside the headline, so a visitor read two columns before seeing anything. - Six identical cards, then six more, then six more became one asymmetric grid — a large panel, a dark tile, an accent tile, a wide module row — so the important thing is allowed to be bigger than the rest. - The industries are one dense two-column list instead of six cards repeating each other's shape. - The page has one dark hinge in the middle (the deployment) rather than a dark section among seven light ones. - The evidence table became rows that pair the measure with the piece of paper you can hand somebody, which is what it is for. - Prices are cards, with the full matrix folded away underneath. The matrix used to be the first thing a visitor met: twelve rows of ticks before a single price was legible. - The FAQ heading sits still while the questions scroll past it. - The footer is a real footer. The header is a floating bar rather than a full-width band, and opaque in both states — translucency plus backdrop-blur reads as a rendering fault over the dark panels that scroll under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
43d58de7f8 |
Rebuild the public website in the shared design system
Reported plainly: the site is still in the old style. It was — it was the last of the three surfaces the token file describes that had never been converted. landing.blade.php carried its own complete design system inside a <style> block: warm paper, IBM Plex Serif headlines, 3px corners, its own scale of greys, written before the tokens existed. Somebody who clicked "Anmelden" left one company and arrived at another. The page now draws from resources/css/portal-tokens.css like the portal and the console: same surfaces, same radii, same button hierarchy, same small-caps label, one family at weight 700 instead of a serif. What still differs is rhythm, which is what a marketing page is allowed to differ in. Content is unchanged. New: components/layouts/site.blade.php holds the header and footer for every public page, and legal.blade.php uses it too — the imprint used to be a stranded card in a third style again. Two things found on the way: - .lbl set `color: var(--muted)`, which is not a token. An undefined var() in `color` computes to `unset`, so every small-caps label on every surface was whatever colour its container happened to be. - The big figures were set in mono. Mono gives the decimal comma a full glyph cell, so "99,95" read as three separate numbers. coming-soon and the status page stay self-contained on purpose — both are served when the application may not have a built stylesheet at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2a06b5dfb2 | Say EU and stop there, as the approved template does | |
|
|
de6821b53e |
Move the console off /admin, give the status page its own address, and measure monitoring
tests / pest (push) Successful in 7m43s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 5s
Details
Three things the owner asked for, and one the review found underneath them. The console leaves /admin. It has its own route file now, registered before routes/web.php because once it has a hostname to itself it sits at the ROOT of that host — where `/` and `/settings` also exist for the portal, and the first matching route wins. Where it mounts is one decision in one place: `/` on the console hostname where it has one to itself, `/admin` on any host otherwise. Names stay admin.* in both modes, so nothing that builds a URL has to know. Exclusivity is its own switch, not a consequence of having a hostname. The first attempt made "this host is the console" follow from ADMIN_HOSTS, and a development machine lists its own IP there so the console works without DNS — which took the public site and the portal off that machine entirely. The switch also registers the console on every listed hostname, canonical last, so the alternates that exist as recovery paths keep working. The status page moves out of /legal, where it sat beside the imprint and the terms. Nothing about the current health of the platform is a legal document. It is a real page now: portal, instances, provisioning and backups, each derived from records, aggregate only, and a component with no signal reports "unknown" rather than "operational". That last rule is what exposed the real bug. monitoring_targets.status was written once — 'up', at provisioning — and nothing ever updated it. Both the console's notices and the new public page read it, so an outage would have been published as healthy indefinitely. There is a sync job now, on a five-minute schedule, and a checked_at column so a verdict can go stale instead of standing forever. The monitoring contract had to grow a third state for that to be honest. isHealthy() answers true when no monitoring is configured and false when the monitor is unreachable; recording that boolean would have published either a fleet-wide all-clear nobody measured or a fleet-wide outage that was really one broken monitor. health() returns null for both, the recorder leaves the old verdict to go stale, and isHealthy() keeps its forgiving semantics for the one caller that wants them — the provisioning acceptance check, which must not fail a delivery because monitoring is not set up. Backups are counted from the instances that need protecting rather than from the backup rows that exist, so an instance with no schedule at all cannot be missing from the arithmetic that declares the estate protected. Also: the update panel polls itself, offers the button only when there is something to install, and opens the log while a run is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
30a80b6c15 |
Report the estate from the database, and print the traffic that is sold
Three console pages were fiction. The front page claimed 42 customers, 39 instances, four hosts named pve-fsn-1..3 and €7,842 a month, over a twelve-month growth curve; the instance list held seven invented machines; the revenue page reported churn and a trend for a business with no recorded history. All of it was hard-coded. It read like a running company and measured nothing. They now read the database. Two figures are gone rather than approximated — the revenue trend, which needs a monthly history nobody records, and churn, which needs a base the data cannot supply. ARR stays, labelled as the projection it is. The green "all systems normal" badge is computed from the notice list instead of asserted, and the notices themselves come from failed runs, hosts reporting errors or gone quiet, and monitoring that is down. Host load is the one number that had to agree with something else: placement counts the VM disk allocation, ignores a failed instance that never got a VM, and subtracts the host's reserve. A dashboard doing its own arithmetic would show a host as comfortable while orders were already being refused on it, so it uses the host's own accounting — with the filter moved into a scope both share, and the sum preloaded so listing hosts stays one query. The instance list drops the Nextcloud version column: that version is not recorded anywhere, and a column filled with a plausible number is worse than no column. Statuses the lifecycle writes but nobody had translated no longer render as "admin.status.failed". The price sheet also gains the included traffic, which the catalogue has always carried and the page simply never printed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b844ff377d |
Rebuild the public site as a document, and read prices from the catalogue
tests / pest (push) Successful in 7m30s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 4s
Details
The marketing page was a generic centred hero in the system font stack: it neither used the design system the console is built on nor looked like the product it sells. Rebuilt around what CluPilot actually promises — security you can produce evidence for — so the page reads as a controlled technical document: a specification plate instead of a hero image, numbered sections, an audit register with rhythm and proof per measure, and a price sheet rather than floating cards. IBM Plex Serif joins Sans and Mono as the display voice, self hosted as static files so the public page renders even without the Vite build. The prices were written into the page by hand, and the page and the catalogue had already drifted apart on three of four plans: the site advertised 249 € for a plan the checkout charges 399 € for. The sheet now reads the catalogue, like every other caller — including the currency, which is configurable and was also hard-coded here. The catalogue fails loudly on purpose; a public website must not. A broken or overlapping catalogue is caught in the controller alone: the page still renders, the sheet is replaced by "on request", and nobody is quoted a number the checkout would not honour. The sign-in and registration panels shared a copy-pasted orange gradient. They now share one component and the same ink plate as the site, so the two halves of the product no longer look like two companies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4336c3bb3f |
feat(brand): CluPilot logo mark + SVG favicon across layouts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
b032d8808b |
feat(landing): public marketing homepage at / (+ legal placeholders)
Public www landing page (your design) as a self-contained Blade view at / — own
CSS/JS, system fonts (no CDN, R14), hero, marquee, typewriter, product mock with
scroll-zoom, security section, honest comparison, pricing gallery, FAQ, CTA. It is
intentionally outside the app token/component system (marketing page, not control
panel). Wired a sign-in link to route('login').
Robustness/a11y hardening from the Codex (R15) loop:
- reduced-motion: pin hero/reveal elements to their visible end state.
- <noscript> fallback so reveal content is visible without JS.
- login link kept visible on mobile (moved out of the hidden nav group).
- stats render their real values in HTML (correct without JS).
- footer legal links now resolve to real /legal/* routes (placeholder pages).
- reconciled contradictory data-migration pricing (add-on from €390).
48 Pest tests green; R12 browser: landing 0 console errors. Codex (R15) clean.
NOTE: the /legal/* pages (Impressum, Datenschutz, AGB) are placeholders — real
legal content must be supplied before this homepage goes public.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|