Skip to content

fix+feat: panel papercuts, FTP password management, inline job progress - #90

Merged
nechodom merged 3 commits into
mainfrom
fix/panel-papercuts
Aug 12, 2026
Merged

fix+feat: panel papercuts, FTP password management, inline job progress#90
nechodom merged 3 commits into
mainfrom
fix/panel-papercuts

Conversation

@nechodom

Copy link
Copy Markdown
Owner

Everything reported in this round, in one branch.

Bugs — each was a value that was right in the database and wrong on screen

SPF and DKIM cards did nothing. The real cause wasn't the button: the cards are lazily swapped into a placeholder that is itself a spinner ("Checking SPF records…"), and HTMX does not swap on a non-2xx. Both handlers opened with two fallible ? (selector parse, hosting lookup) before rendering anything, so any failure there left that spinner on screen permanently. That's why the earlier fix to the dispatch arms didn't help — they're downstream. Both endpoints now render the card with the reason.

"No successful backup yet" forever. mark_ok writes state='ok'; the health card filtered state == "done", a string nothing has ever written. It also docked 15 points off the health score. The dashboard alert had it right, which is why the two disagreed.

Imported WordPress sites never appeared under WordPress updates. The sweep required a row in the wordpress table — written only when Hyperion installs WordPress. Every CloudPanel/HestiaCP import, restore, or customer-uploaded site was skipped: never scanned, never auto-updated, invisible. Silently, and precisely for the sites most likely to be running something old. It now asks the disk when the table has nothing.

Expiry showed expires 1818115200, and the required "Expires on" field was always blank — so editing the grace period meant re-typing a date already on screen. Readable now, and prefilled from the existing expiry, falling back to the next billing date.

Σ all sites looked like it was outside the table. tbody tr defines a surface; tfoot tr did not, so it inherited the page behind the table.

FTP — and a credential leak found on the way

The card could only generate a password. There's now a field to type one (empty still generates), and per-row actions on the accounts table to rotate or delete an individual account — each acting on its own hosting, never the page you're looking at.

The security part: the generated password was returned in the redirect's query string — landing in browser history, in Referer, and in nginx's access log in plaintext. It now goes through a single-use in-memory hand-off; the URL carries only a token, consumed on first render, expiring in five minutes, never persisted.

Inline job progress

Backups/restores stay background jobs (that's what survives a disconnect), but the Backups tab now polls a per-hosting panel and draws the progress bar in place — no reload, no leaving the tab. Renders nothing when idle, keeps polling either way so a job started elsewhere appears on its own, and degrades to an empty div rather than an error.

Backups on disk

Archives live outside every site's home directory, so neither disk figure on /stats counted them — on a filling-up box they're often the reason. NodeStats now carries the total and count, summed live rather than sampled.

Tests

cargo test --workspace green, cargo fmt --all clean.

🤖 Generated with Claude Code

mkn and others added 3 commits August 12, 2026 10:43
…kup health check

Three unrelated bugs, all the same shape: a value that was right in the
database and wrong on the screen.

EXPIRY. The pill printed a raw unix timestamp — `expires 1818115200` is
not a date to anyone. It now renders through the date filter, with the
raw value kept in the tooltip. The "Expires on" input was also always
blank, even when an expiry was set, and the field is `required` — so
editing the grace period or the owner e-mail meant re-typing a date that
was already on screen two lines above. It is now prefilled from the
expiry already set, falling back to the next billing date, which is the
right answer nearly every time: a hosting is normally paid up to the day
it would expire.

WORDPRESS DETECTION. The vuln/update sweep skipped any hosting with no
row in the `wordpress` table. That row is written when HYPERION installs
WordPress, so the sweep was blind to every site that arrived any other
way — a CloudPanel or HestiaCP import, a restore, a site the customer
uploaded themselves. Those were never scanned, never auto-updated, and
never appeared on the WordPress-updates page, silently, and precisely for
the sites most likely to be running something old. It now asks the disk
when the table has nothing; wp_status writes the row as it goes, so this
costs one filesystem probe per imported site rather than one per tick.

BACKUP HEALTH CHECK. `mark_ok` writes `state='ok'`, but the hosting health
card filtered on `state == "done"` — a string nothing has ever written.
So "Recent backup" read "No successful backup yet" forever, however many
backups had succeeded, and docked 15 points off the health score for it.
The dashboard alert had the comparison right, which is why the two
disagreed with each other.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ing forever

Both cards are lazily swapped into a placeholder that is itself a
spinner — "Checking SPF records…". HTMX does not swap on a non-2xx, so
any error status from the panel endpoint left that placeholder on screen
permanently. Both handlers opened with two fallible `?`s (selector parse,
then the hosting lookup), so a failure in either produced exactly the
symptom reported: a spinner appears, nothing else ever happens, and the
operator has no way to tell a hung request from a broken lookup from a
button that does nothing.

"A spinner that never resolves" is the least informative failure a UI can
have. Both endpoints now render the card WITH the reason instead of
returning a status HTMX will discard. The dispatch arms were already
careful about this — the comment above them says never render nothing —
but the two lookups before them were not, which is why the earlier fix to
those arms did not help.

DKIM reuses the existing render_dkim_error_card rather than adding a
second copy; the POST actions already did the same thing, so this only
brings the GET panel in line.

Also fixes the Σ totals row on /stats rendering as though it were outside
the table: `tbody tr` defines a surface and `tfoot tr` did not, so the
footer inherited the page behind the table and read as floating below it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sage

FTP. The card could only GENERATE a password — there was no way to type
one, which is what you need when the credential has to match something
that already exists. There is now a field for it (empty still generates),
labelled Set or Change depending on whether the account has one. The
per-node account table gained per-row actions, so a password can be
rotated or an account deleted without first navigating to that site: each
row acts on its OWN hosting, never on the page you happen to be looking
at, or the wrong site's access would be revoked.

The backend already did all of this — ftp_set_password treats an empty
string as "generate" and enforces a 12-character minimum otherwise, and
ftp_disable revokes. Only the UI was missing.

SECURITY, and the reason this is not a pure UI change: the freshly
generated password was handed back in the redirect's QUERY STRING. That
put a live credential into the browser history, into the Referer of
anything the page loads, and — the one that matters — into nginx's access
log in plaintext, for as long as logs are kept. It now goes into a
single-use in-memory hand-off and the URL carries only a token, which is
consumed on first render, so a reload or a shared link cannot show it
again. Entries expire after five minutes; nothing is persisted, because a
password that survives a restart is a password sitting somewhere it does
not need to be.

INLINE JOB PROGRESS. Long actions are background jobs so they survive a
disconnect, and the button that starts one navigates to /jobs/<id> to
watch it. That is right and stays — but it also means leaving the tab you
were working in, and returning to it later told you nothing about what
was still running. The Backups tab now polls a per-hosting panel and
draws the same progress bar in place. It renders nothing when nothing is
running, keeps polling either way (so a job started in another tab
appears on its own), and degrades to an empty div rather than an error,
because a panel that stops polling on the first hiccup silently stops
being live.

BACKUPS ON DISK. Archives live outside every site's home directory, so
neither disk figure on /stats accounted for them — on a box that is
filling up they are very often the reason, and nothing said so. NodeStats
now carries the total and the count, summed live rather than sampled so
the figure never lags a delete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nechodom
nechodom merged commit 3327556 into main Aug 12, 2026
1 check passed
@nechodom
nechodom deleted the fix/panel-papercuts branch August 12, 2026 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant