Both toasts printed the endpoint's own message field verbatim, so a Russian UI
answered "Terminated 0 sessions". That string is assembled in Python and can
never be translated where it is built, so the endpoints now also return the raw
count and the phrase is composed on the client.
message is kept: it goes to the audit log and may have other consumers, and
adding a field breaks no one.
Russian needs three forms, and zero does not land in the obvious one — 0 is the
"many" category ("сессий", not "сессии") — hence four keys against English's
two.
Fixes both call sites: the per-user button in the table row and Terminate all
sessions on the Sessions tab, which shared the same untranslated string.
The wizard already refuses to open once a limit has no headroom left, but the
button leading to it still looked available, so the only way to find out was to
click it. Reuse exhaustedMetrics() from the wizard's quota module, so the button
and the wizard cannot disagree about what counts as exhausted, and skip the
check for admins for the same reason the wizard does — their own quota says
nothing about an instance they create for someone else.
The button is rendered as a Link, and disabled does nothing to an <a>: it would
still navigate. So the exhausted case renders a plain button with no Link, and a
tooltip naming the limits that are full. The trigger wraps it in a span because
a disabled button receives no pointer events of its own.
The wizard keeps its own block, which is what still covers navigating straight
to /instances/create.
Rebuilding the frontend surfaced the limiter sitting behind the request-rate
one fixed in 935c804: with limit_req no longer rejecting the bundle, the
cold load started failing on `limiting connections by zone "addr"` instead,
and the page went blank again.
nginx counts each concurrent HTTP/2 stream against limit_conn, and a browser
multiplexes the entire page over a single connection, so `limit_conn addr 10`
capped the whole page at ten in-flight requests. That is far below the 89
files the build emits, and it would equally have throttled the parallel API
queries the dashboard issues after login — the login screen just happened to
be where it showed.
Raise the server-level cap to 100 and give the static-asset location 200.
Verified against the live deployment: 89/89 assets return 200, the login
page renders, and nginx has logged no limiting events since the reload.
deploy.sh, bootstrap.sh, update_host.sh, setup_ssh.sh and pve all carry a
#!/bin/bash shebang and are invoked directly, but git recorded every one of
them as 100644. A fresh clone therefore could not run ./deploy.sh, and any
host where the bit had been fixed by hand showed a permanently dirty
working tree — the mode was the only difference.
Record the bit so a clone is runnable and `git status` stays clean.
nginx/conf.d/pvemanager.conf is not source: setup_nginx_config() writes it
on every deploy by copying one of the .template files and substituting the
domain. Tracking it meant the repository carried one deployment's hostname,
and every deploy showed the file as modified.
Untrack it and ignore it. The templates it is generated from stay tracked,
so nothing is lost — a missing copy is restored by re-running deploy.sh, or
by hand:
cp nginx/conf.d/pvemanager.conf.template nginx/conf.d/pvemanager.conf
sed -i "s/DOMAIN_NAME/<domain>/g" nginx/conf.d/pvemanager.conf
Note for existing checkouts: this commit deletes the path, so a deployed
host that pulls it will lose its live config unless the file is regenerated
afterwards.
compose.prod.yml has always intended to take the app and frontend off the
host interface and let nginx front them — "remove port exposure (nginx will
handle it)". It did not work: Compose concatenates list-valued fields when
merging -f files, so `ports: []` contributed nothing and 8000:8000 and
3001:80 survived from compose.yml.
The effect was that an SSL deployment still answered on both ports over
plain HTTP, bypassing TLS, HSTS, the security headers and the rate limits —
confirmed against the live host from off-box, where the API returned its
health payload unencrypted on :8000 and the panel loaded on :3001.
Use the !reset tag so the inherited value is dropped rather than extended.
Verified in place: the merged config now publishes only 80 and 443, both
former ports refuse connections from outside, and the panel still serves
over HTTPS.
Loading the panel over the domain produced a white page while the same
panel served directly from the frontend container rendered fine. The built
SPA requests 86 files on a cold load — one entry module plus 85 preloaded
chunks — and the server-level
limit_req zone=general burst=20 nodelay; # general = 10r/s
applies to those static assets too. Measured against the live site, 60 of
the 86 came back 503 and nginx logged "limiting requests ... by zone
general" for each. The entry module was among the casualties, so React
never booted and the document stayed empty.
Give static assets their own zone and restate the limit in a location that
matches them. The limit has to be restated rather than omitted: nginx
inherits limit_req from the enclosing level only when a location declares
none, and there is no `limit_req off`.
Both templates get the block, since the non-SSL config serves the same
bundle. Verified on the live deployment: 86/86 assets now return 200 and
the login page renders with no console errors.
nginx.conf configures 2048 worker_connections, but Docker's default
RLIMIT_NOFILE of 1024 capped what the workers could actually open — and
since a proxied connection needs a descriptor both downstream and upstream,
the real ceiling was about a quarter of the configured one. nginx warned
about exactly this on every config test.
Raise nofile to 8192 for the nginx container. Verified in place: the limit
goes from 1024 to 8192 and `nginx -t` is now free of warnings.
`listen 443 ssl http2` has been deprecated since nginx 1.25.1 in favour of
a separate `http2 on;` directive, and Let's Encrypt no longer publishes an
OCSP responder URL in its certificates, so `ssl_stapling` had nothing to
staple. Both warned on every reload — noise that now matters more, since
nginx reloads itself every 6h to pick up renewed certificates.
Reloads are clean afterwards and HTTP/2 is still negotiated.
The SSL deployment aborted even when Let's Encrypt had issued a perfectly
valid certificate. obtain_ssl_certificate() confirmed success with
[ -d "nginx/certbot/conf/live/${domain}" ]
but certbot runs as root inside its container and creates live/, archive/
and accounts/ with mode 0700. Whenever deploy.sh runs as a non-root user
that test fails with EACCES no matter what is on disk, so every SSL deploy
fell back to the HTTP-only config and printed "Failed to obtain SSL
certificate" right after certbot reported the certificate was fine.
Check the certificate from inside a container instead, and treat the files
on disk as the source of truth rather than certbot's exit code, so a
certificate that is present and not yet due for renewal counts as success.
Pass --keep-until-expiring to make that case explicit.
Also drop the recursive chmod on nginx/certbot: it could not touch the
root-owned files it was aimed at (producing the wall of "Operation not
permitted" noise) and would have exposed the private keys if it had.
Verify nginx actually came up after switching to the SSL config and roll
back to HTTP if it did not — a crash-looping nginx takes the whole panel
offline, which is worse than serving without HTTPS.
Reload nginx every 6h so renewed certificates are picked up: nginx reads
the certificate once at startup, so the renewal loop would otherwise keep
serving the old one until the container happened to restart.
Ignore nginx/certbot/ — it holds Let's Encrypt private keys and was
untracked but not ignored.
setting:view used to gate the whole /settings page, which mixed panel-wide
configuration with a user's own account. Revoking it to keep a standard user
out of the panel settings also took away their profile, password, 2FA and SSH
keys, so the permission could not be used for what it names.
The account half now lives at /profile: its endpoints are self-scoped and only
require a session, so the page carries no permission at all and is reached from
the user menu (and Ctrl+K) rather than a sidebar group. /settings keeps the
panel-wide tabs behind setting:view, with saving gated on setting:update,
security on setting:manage and the SMTP/Telegram channels on is_admin, matching
what each endpoint checks.
The shipped "user" role no longer gets setting:view. Migration 42 drops it from
roles holding it without setting:update/manage, since those grants only existed
to reach the profile page; it runs once (marker in panel_settings) so a later
deliberate grant is not reverted on the next start.
Hiding a sidebar entry was the only frontend gate: /users, /logs and
/settings opened for anyone who typed the URL, and their pages rendered
every tab regardless of the caller (the backend then answered the tab's
requests with 403s).
Route elements are now wrapped in RequirePermission, gated on the same
permission the page API requires, so a hidden menu item can no longer be
reached by URL. Denied users land on the first page they can open, or on
an explanatory screen when nothing is reachable, so the redirect cannot
loop. Permission checking moves into lib/permissions.ts and canSeeNavItem
delegates to it, keeping menu and guard in sync.
Settings hides the panel tab without setting:update, the security tab
without setting:manage, the SMTP/Telegram cards from non-admins, and the
update controls behind setting:manage; profile, SSH keys and personal
notification preferences stay available to everyone. Users hides the
roles tab without role:view and the security tab without setting:manage.
Quotas were only enforced by check_quota() on the backend, which answers 429
from the deploy endpoint — so a user walked all five wizard steps and learned
about the limit on the very last click. QuotaHint coloured the offending number
red but blocked nothing.
Split the two situations the UI never distinguished:
* No headroom at all (used >= limit): no configuration would ever fit, so the
wizard does not open. It is replaced by a message naming exactly which
limits are used up, with their numbers.
* Headroom left but the chosen configuration does not fit (used + requested >
limit): the wizard opens, and from the config step onwards it names the
metric and disables Next/Deploy until the resources are reduced.
The overrun check deliberately stays quiet until the config step. Gating every
step meant a default size that did not fit the remaining quota stranded the user
on the server picker, with no way to reach the fields and shrink it.
Neither block applies to admins: their own quota says nothing about an instance
they are creating on behalf of someone else, and blocking on it would lock an
admin with a quota out of the wizard entirely. The figures are still shown.
Also label the quota card as a projection. It renders used + the instance being
created, so a user with 3 of 4 instances saw "4 / 4" and reasonably read it as
already exhausted — which is what prompted this.
The arithmetic lives in features/instances/quota.ts as pure functions rather
than inline in JSX, so the two thresholds can be reasoned about on their own.
Instances deployed while save_vm_instance() dropped the owner are still sitting
in the database with owner_id = NULL, so their users cannot see them. Recover
the creator from the two places it was recorded:
1. deploy_tasks — user_id is a real foreign key stored when the deploy was
queued. server_id is absent for LXC deploys, so a NULL there is treated as
matching any server instead of discarding the row.
2. audit_logs — vm_create / ct_create / lxc_create entries, which only carry a
username and are matched by name.
Ownership decides who may see, stop and delete an instance, so a wrong guess is
worse than no owner at all: a row is updated only when every applicable piece of
evidence names the same existing user. VMIDs get reused, so conflicting evidence
is expected and those instances deliberately stay ownerless for an operator to
assign with assign_instances_owner.py. Existing owners, soft-deleted rows,
failed create events and logs from deleted users are all left alone, which also
makes the migration idempotent.
Note this recovers who created an instance, not who it was meant for: a VM an
admin created on behalf of a user is attributed to the admin, because the
intended owner was never persisted before the fix.
The attribution rule is a pure function (pick_backfill_owner) so it can be
tested directly. The migration's own statements are portable, so the tests run
it against SQLite with the two information_schema helpers stubbed out.
Newly created VMs/LXCs ended up with owner_id = NULL, and a VM created by an
admin on behalf of a user received the admin's SSH key instead of the user's.
Ownership: save_vm_instance() only set owner_id when inserting a new row. The
row practically always existed by then — sync_vm_cache runs every 10s and
registers VMs it sees on Proxmox with owner_id = NULL while the clone is still
running, whereas the deploy saves the owner minutes later at 95% progress. So
every deploy landed in the update branch and silently dropped the owner. Set
owner_id there too, but only when it is passed explicitly: callers such as
save-before-delete do not know the owner and must not blank it out.
SSH keys: the profile key was taken from whoever pressed "Create" rather than
from the instance owner, and the LXC path injected no profile key at all. Add
build_deploy_ssh_keys(), which merges pasted keys, keys picked from the library
(same authorization rules as before) and the owner's profile key, deduplicated.
Both the template and LXC deploy endpoints now use it, so the key material is
resolved in one place instead of inside the deploy worker.
ISO VMs are left alone on purpose: they are blank VMs without cloud-init, so
there is nowhere to inject keys.
Covered by backend/tests/test_instance_ownership.py, including the sync race.
The proxmox router is mounted under the /proxmox prefix in main.py, so the
real create-server endpoint is /proxmox/api/servers, not /api/servers as
the registration step assumed. The bare /api root only has /api/auth/*.
Verified end to end against a real Proxmox host: LXC provisioning, Docker
install inside it, deploy.sh --standalone build+run, admin login, local
root@pam token creation, and registration all completed successfully,
with the panel reporting the new node online.
On an unsubscribed Proxmox host, apt's enterprise repo answers 401 and
apt-get update exits non-zero even though every other source succeeded,
aborting the whole bootstrap under set -e before git/curl were ever
installed. The subsequent apt-get install still fails loudly if a package
genuinely can't be found, so tolerating this specific failure is safe.
Found by running bootstrap.sh against a real Proxmox host.
Server card used Math.max over all cluster nodes' uptime, so
rebooting one node still showed another node's higher uptime,
making it look like the reboot had no effect.
Adds bootstrap.sh implementing Stage 3 of the roadmap: it detects whether
it runs on a plain Debian/Ubuntu host or directly on a Proxmox VE host.
On a PVE host it provisions a Debian 12 LXC, installs the panel inside it
via the existing deploy.sh, then creates a root@pam API token locally and
registers the host as the panel's first node — no Proxmox password is
ever transmitted.
Also removes the insecure pvemanager_secure_password fallback for
POSTGRES_PASSWORD in compose.yml, since every deploy path already
generates a real .env before the stack starts.
- Images: show real ISO files already on the node storage (incl. those
present before the node joined the panel) via existing node/isos endpoint
- ISO dialog: rename "detach" to "Eject", show current image inside the
select (drop overflowing side text), add "boot from disk" checkbox
- Backend: on eject, put disk first in boot order; on mount, optional
"boot from this ISO" puts CD-ROM first
- Reboot to apply the new boot order via hybrid restart: graceful ACPI
shutdown with forceStop fallback, then start (fixes guest-ping timeout
on freshly installed OS without QEMU Guest Agent)
- Update iso_next_steps hint to reflect the automated flow
The per-column "Node" filter memoized its option list against TanStack's
Column object, which keeps the same identity across data updates, so the
checklist stayed frozen at whatever nodes were visible when the popover
first mounted instead of reflecting the current (workspace-scoped) rows.
Switching the active workspace also left the server/node/column filters
holding values from the previous workspace, which could filter the newly
scoped VM list down to nothing.
Replace free-text storage inputs with dropdowns populated from the
server/node's actual storages (filtered by vztmpl/rootdir content),
matching the pattern used elsewhere in the app.
Add detect/import flow for nodes already clustered on the Proxmox side
(outside the panel). Matches nodelist entries from cluster/config/join
against known panel servers by IP/hostname and links their panel
records to the cluster_name group, without running any pvecm operation.
Adds a local file upload path alongside the existing URL download flow:
a new /images/upload endpoint streams the file to a temp path and hands
it to a background worker that proxies it into Proxmox storage via the
upload API (requests-toolbelt handles the large multipart stream). Also
adds /images/resolve-filename to recover the real filename (Content-
Disposition) for URL-based downloads with opaque paths.
Raise nginx client_max_body_size and timeouts on both the frontend proxy
and the standalone nginx configs so multi-GB ISOs don't get cut off.
- Invalidate workspace list cache after assigning servers/users so
server/user counts on workspace cards update immediately.
- Invalidate node/VM caches after workspace server/user assignment so
the Nodes page reflects visibility changes right away.
- Soft-delete the cached VM/LXC row and broadcast vm_deleted the moment
a VM/container is destroyed, instead of waiting for the 10s
background sync_vm_cache tick.
- Invalidate the VM list on the frontend when a deploy task completes,
since the backend already persists the new instance to cache
synchronously before marking the task done.
Extract ServerStatusBadge so every server list renders the auth-error
state identically instead of repeating the online/offline ternary, and
expose last_error_kind from the cluster topology and user-assignment
endpoints, which build their payloads by hand and so did not inherit it
from the server schema.
Add a third instance kind to the create wizard: a blank KVM VM with an
empty disk, the installation ISO mounted on ide2 and put first in the
boot order, so the guest boots straight into the installer over noVNC.
Picking a Windows guest presets OVMF, a SATA disk and an e1000 NIC,
which the installer sees without virtio drivers loaded.
Also teach the image downloader content=iso, so ISOs can be fetched
onto a node from the panel: by URL from the images page or from a saved
mirror (mirrors now accept kind=iso), and straight from the ISO step of
the wizard.
The bundled catalog stays qcow2-only on purpose — install ISOs have no
stable "latest" URLs, so hardcoded links would rot on every point release.
deploy.sh already covers most of the installer (OS detection, Docker setup,
random secret generation, nginx, TLS, image build, pve CLI), so the stage is
about publishing a thin bootstrap rather than writing one from scratch.
Records the decisions taken: the bootstrap detects its environment and
creates an LXC when run on a PVE host, the repository is public, and images
keep being built locally. On a PVE host the node can register itself without
a password via pvesh, reusing the token provisioning from stage 1.
An offline server used to report a single "Failed to connect" state, but
"the node does not answer" and "the node answered 401" are fixed in
completely different ways — the first is power or network, the second is a
stale token, which is what happens when a node joins a cluster and its
user.cfg and priv/token.cfg are replaced by the cluster ones.
- add ProxmoxClient.check_connection() returning (ok, error_kind, error);
token auth surfaces 401 as ResourceException rather than
AuthenticationError, since proxmoxer validates the token on first use
- store the reason in proxmox_servers.last_error_kind (migration 40) and
expose it through the server response
- surface an auth_error state in the UI with a hint explaining the usual
cause
Storage listings now also report per-node availability: the cluster config
declares a storage for the whole cluster but says nothing about whether it
actually came up on a given node, so a missing volume group or pool stayed
invisible. Availability comes from a single /cluster/resources call.
Records the six agreed directions with their status, the files each one
touches, the steps involved and the decisions already made, so the work can
be picked up without prior context.
Servers can now be connected with a login and password: the panel
authenticates once, creates its own API token on the node (privsep=0) and
uses it from then on. The token can be revoked from the Proxmox UI, cannot
be used for SSH or web login, and appears in the PVE audit log under its
own name. The password stays stored (encrypted) because ProxmoxClient needs
it for SSH fallbacks such as pvecm delnode.
- add proxmox/token_provisioning.py: token creation with free-name
selection and revoke-on-failure so a failed registration does not leave
an orphaned token whose secret was never persisted
- POST /api/servers accepts auto_create_token
- add POST /api/servers/{id}/provision-token to migrate servers that are
still on password auth
- store api_token_value as EncryptedString; it was kept in plaintext while
the password next to it was already encrypted
- drop the unused ProxmoxServerBase schema, a stale copy of the create
schema whose validator rejected the new auth mode
Node detail pages gain a Graphs tab (CPU, IO delay, memory, load average,
network, swap, root filesystem) built on the existing node rrddata
endpoint. The chart card is extracted from the instance graphs tab so both
views share it, and X axis ticks now switch to dates on multi-day ranges.
The overview tab rendered "no data" while the VM list was still loading, so a
server with instances looked empty until the request came back. Gate the empty
state on isLoading and show skeleton rows instead.
Also add the missing nodes.instances_on_server key to both locales — the raw
key was rendered in place of the heading.
/api/cluster/topology and /api/resources/all read every ProxmoxServer row
regardless of the caller access or the X-Active-Workspace header, so the
Cluster page listed all servers and dashboard aggregates counted the whole
panel instead of the active workspace. Extract the access + workspace filter
out of /api/servers into get_visible_server_ids() and apply it in all three.
Live metrics were also gathered server by server with the cache lock held
across the network calls, so a single unreachable cluster stalled the request
(21-31s in the logs) and concurrent requests queued behind it. Fetch in
parallel, skip servers already known offline, drop the redundant
is_connected() probe, and hold the lock only around the cache dict.
/api/resources/all now orders by server id: without it Postgres returned heap
order, which shifts whenever update_status rewrites a row, so the server list
reshuffled between requests.
Shift dark-theme background, card, popover, sidebar and border/input
tokens from neutral hue 286 (near-zero chroma) to a blue-graphite
hue 258 with higher chroma, so the panel reads as midnight-blue
instead of flat black.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite the RBAC/Security section with a detailed model: permission
format, engine evaluation order (admin bypass, wildcard, manage-implies),
requires dependencies, full permission catalogue, per-page permission
map, built-in role breakdown, and the ownership-based user isolation
(vm:manage, not vm:view, grants the global view). Fix stale references
(version 1.10.2, Networks needs server:manage, SSH keys user:manage).
Add WIKI.ru.md as a complete Russian translation mirroring WIKI.md,
including the expanded RBAC section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The panel update status lived only in-memory and was cleared solely by the
host watchdog restarting the container. If the rebuild failed mid-way (e.g. the
build OOMs the host when no swap is configured), the container never restarted
and the UI stayed at "restarting - 80%" forever.
- update_service: get_update_status() now self-heals — after UPDATE_TIMEOUT_SECONDS
(default 900s) of "is_updating" it transitions to a terminal "failed" state with
a clear error pointing at the host update service / logs.
- use-settings: poll update status while the server reports is_updating (not only
right after the click in the same tab), so reloading a stuck page keeps refreshing.
- SettingsPage: banner now renders the "failed" state with the error message.
- update_host.sh: build images sequentially (app, then frontend) to lower peak
memory and avoid the OOM that killed the build mid-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>