The dialog filled its texts with String.replace, which reads $& and $'
in the replacement as patterns: a host named c$&1 showed as
c%(hosts)s1. The values now go in through a function, which inserts
them as they are.
Which users can reach the API with a token was seen only by opening each
user. The user list now has an API tokens column: the number of the
user's tokens that still work (expired ones are left out; they are
deleted only at the user's next token), linked to the user's token tab.
One query counts them for all users.
Cloud images read their first-boot settings with cloud-init. The panel
served them only over HTTP, which a VM reaches only with the network
and a reverse DNS name for its address. A new VM can now carry them
itself: Cloud-init seed (Advanced) on the create form, or cloud_init in
the API.
The job writes a NoCloud seed, an ISO labelled cidata, into the disk's
directory pool as cidata-<uuid>.iso and puts it in the VM's CD-ROM
drive. Its meta-data holds the VM's UUID as instance-id and its name as
hostname, written as JSON so a VM named 123 or yes keeps a string
hostname; its user-data is what the HTTP datasource serves, with the
creating user's SSH keys. The ISO is built with pycdlib and written
through a libvirt stream, as the NVRAM copy is, now one helper for both.
A seed needs a CD-ROM bus and a directory pool, else the create is
refused. A failed create deletes the seed with the new disk, recovery
names one left behind, and deleting the VM with its disks deletes its
seed, found in the pools by name even when ejected. A clone's drive is
left empty: the seed is the source VM's identity.
When the guest agent does not answer a root password or SSH key change
in time, the command was already sent: the agent runs it when it
answers again. Measured: with the agent frozen the change failed with
"guest agent command timed out", and after it resumed, root's hash was
the one sent. The error now adds "The change may still be made when the
agent answers again." A failed synchronization sends no command and
gets no note.
A quiesced snapshot and a live clone freeze the guest's file systems
through libvirt, which asks the agent without a timeout: with the agent
hung, the job waited, holding the VM, with no end.
The agent must now first answer a guest-ping within 10 s; a frozen or
dead agent fails the job in seconds, before anything is frozen. The
freeze itself stays libvirt's, without a timeout: a timed freeze was
tried and left the guest frozen, since the freeze still ran in the
guest when the agent came back, after its thaw had failed.
The IP search and the VM report read each running VM's addresses and
OS from its guest agent with interfaceAddresses() and guestInfo(),
which wait for a hung agent indefinitely. The search answered by its
deadline, but the thread stayed blocked, counted against the search
thread limit and held up the worker's exit.
They now use the 10 s agent command the VM page uses, shared as
vrtManager.instance.agent_command() and agent_interfaces(). With the
agent frozen, a search answers in 5 s from the leases and the ARP
table, counts the VM unread, and its thread ends about 5 s later.
The VM page read the guest's addresses with interfaceAddresses() from
the agent, and the OS details with agent commands without a timeout.
A connected agent that hangs (a frozen guest) kept the page from
loading at all and held a worker: seen still loading after 120 s.
Both now go through the 10 s guest agent command the root password and
SSH key use. The addresses come from guest-network-get-interfaces, in
the form the page's address code already reads; an agent that does not
answer leaves the ARP address. With the agent frozen the page loads in
about 10 s and the OS details answer with the agent's error (400) in
5 s, instead of a server error.
The Access tab's Root Password and SSH Keys worked only on a shut-off
VM, through gstfsd on the compute. A running VM whose QEMU guest agent
is connected now gets them through the agent: guest-set-user-password
with the SHA-512 crypt hash, and guest-ssh-add-authorized-keys adding
the key to root's keys. A shut-off VM still goes through gstfsd; any
other VM is refused with "Shut the VM down, or start its QEMU guest
agent, and try again", and the buttons are disabled.
Each is one agent command with a 10 s timeout. libvirt's own calls for
them (setUserPassword, authorizedSSHKeysSet) wait for a hung agent
indefinitely, holding a worker and the VM's lock, and the domain's
agent timeout would change every other agent call of the VM too.
The agent's or libvirt's error is shown as is through error_text, so
users who do not see the hosts get no connection details, and the
attempt is logged as failed. The README names the SELinux boolean a
RHEL-family guest needs before its agent may add SSH keys.
The user page a superuser opens from the user list has an API Tokens
tab: the user's tokens with their name, first characters, creation,
expiry and last use, and a delete button for one that may have leaked.
The token itself is never shown; only its digest is kept.
The delete reuses the owner's delete view with the user's id; only a
superuser may name another user. The confirmation and the log row say
whose token it was ("API token backup of alice"); the owner's own
confirmation now reads "Delete "API token backup"?" too. The profile
and the user page share one token table, and one script that opens a
tab named in the address (#apitokens).
Under each host's CPU and memory load, the dialog shows one line:
Given to VMs: 24 vCPUs (1.5:1), 48.0 GB memory.
Pool default: 120.0 GB free.
The line is read when the dialog opens, from computes/<id>/capacity/,
as plain text, so the VM list itself reads no VM definitions. The CPU
bars and the lines share one queue, host by host and two reads at a
time, as each read holds a worker. A host that cannot be read says so.
Compute.capacity now also gives the vCPUs per CPU and its pools with
the default one first, so the Computes page and the dialog share them.
The capacity read lived in its own module with raw libvirt calls and a
thread pool with a deadline, beside helpers that already read the same
things. It now uses them:
- wvmHostDetails.get_capacity() sums the vCPUs and memory of all and of
the running VMs from get_host_instances(), against the host's info.
- get_storages_info() also gives each pool's free space.
- Compute.capacity joins the two, like cpu_count and ram_size, and is
None when the host is down or the read fails.
The page reads the hosts one after another, as it already connected to
each of them; a host that cannot be reached is given up after the
connect timeout. computes/capacity.py and its thread handling are gone.
The page looks and sorts as before.
Each host is a row of what it has committed, read live from the host (in
parallel, by a 10 s deadline):
- Host: a status dot, the name, its address and details; when it cannot
be reached, why.
- vCPUs: the overcommit ratio, large (warning above 2:1, red above
4:1), over "N vCPUs on M CPUs" and the running VMs' share.
- Memory: memory given to all VMs against the host's memory, as one bar:
running VMs dark, shut-off VMs light (they take their memory again
when they start), and a line where the host's memory ends; red when
the VMs were given more than the host has.
- Storage: the default pool (or the first) with its used share and free
space; the others, a host may have dozens, open on a click.
A host that cannot be reached, or does not answer in time, says so in
one line instead. vCPUs and Memory sort by ratio and share. A host whose
read hangs is not read again until that read ends, so a stuck host keeps
at most one thread. The table's hidden status texts stay inside it, so
the page does not widen on a phone.
The API Tokens tab gets a "Last used" column, so an unused token can be
told from one a script still needs. The token's last use is written when
it authenticates, at most once a minute (accounts.auth.TokenAuthentication,
knox's class with that one addition; the API schema describes it as
knox's).
A user has at most ten tokens; a new one is refused until one is
deleted. Expired tokens do not count, and are deleted when a new token is
made.
knox's first migration creates its AuthToken table even when
KNOX_TOKEN_MODEL replaces it with accounts.ApiToken. The unused
knox_authtoken table references auth_user, and Django, which does not see
it, does not empty it with the others: on PostgreSQL every test that
empties the database failed ("cannot truncate a table referenced in a
foreign key constraint"). SQLite does not check this, so only CI's
PostgreSQL leg showed it. knox stays installed for its token code, with
its migrations turned off (MIGRATION_MODULES).
The REST API took only the login session, so a script could not use it
without a browser. A user now makes API tokens on the profile page (API
Tokens tab): a name, and an expiry of 30, 90 or 365 days or none. The
token is shown once, on a page that is not cached, and then kept only as
a SHA-512 digest; the list shows its name, first characters, creation
and expiry, and a token is deleted there. Tools send it as
"Authorization: Token wvc_...", and act as its user, with that user's
permissions. Making and deleting a token is logged, without the token.
A password alone (HTTP Basic) still does not reach the API, as it would
skip the OTP second factor: a token is made in a session that passed the
login and its OTP. The token class comes first, so a refused request gets
401 with its challenge instead of 403.
django-rest-knox 5.1.0 provides the tokens (accounts.ApiToken adds the
name); settings.py takes "knox", KNOX_TOKEN_MODEL and REST_KNOX from the
template.
The search's request was sent on to the login page, and the dialog said
"The search did not go through. Try again." The page now reloads, which
goes to the login form and back to the Instances page after signing in.
A paused guest cannot answer its agent, so the address search and the VM
report asked it nothing, but counted the VM neither as without an agent
nor as not read: the coverage line read as if every address had been
seen. It now counts among the agents not read, and the line names the
paused case.
The "Find IP" dialog takes a MAC address (aa:bb:..., aa-bb-... or
aabb...) as well as an IP address or a network. A MAC finds the VMs with
a network card of that MAC, shut-off ones too (from their definitions),
with that card's addresses when the VM runs: for a MAC seen on a switch
port or in an external DHCP server's log. A MAC on more than one VM is
marked as a possible conflict. Only the VMs with that MAC are read, and
the card's link-local address, which comes from the MAC, is left out.
The Administration menu gets "VM report (.xlsx)", a workbook for
superusers. The VMs sheet has one row per VM on every compute, shut-off
ones and those the panel has not registered too: name,
state, host, OS and OS family, vCPUs, memory, IP addresses and where they
came from, MAC addresses, owners, and whether the panel has registered
it. A Coverage sheet says when it was made and what could not be read.
The addresses come from the same live reading as the address search,
now shared by both. The OS is the guest agent's (libvirt's guest info)
or, without one, the libosinfo id a tool such as virt-install wrote into
the definition. Link-local IPv6 addresses are left out of the report.
Text cells stay text: a VM name starting with "=" is not a formula.
openpyxl 3.1.5 writes the workbook.
The Instances page's filters and the address search become one control:
the state list, the search box and a "Find IP" button. "Find IP" opens a
dialog (with the address typed in the search box, if any). An IP address
or a network (CIDR) finds the running VMs whose network cards use it,
read live from libvirt within 5 seconds: libvirt's DHCP leases, the host's
ARP table and the guest agent. Only addresses on the VM's own NICs count,
so interfaces inside the guest such as docker0 never match. Each match
shows the VM, its MAC, where the address came from, and a possible-
conflict mark when two VMs report one address. A coverage line says how
many running VMs were checked, how many had no connected guest agent (a
static address on them cannot be seen), which agents could not be read,
which hosts were not reached or not finished in time, and when it was
read.
The hosts are read in parallel: leases and ARP first, then each guest
agent on its own thread, so an agent that never answers holds back only
its own VM; libvirt waits for an agent without limit, so a search starts
only while its threads fit in 16 search threads in the worker.
Administrators search every compute, VMs the panel has not registered
yet included, and see the hosts; other users find only their own VMs and
see no hosts or host errors.
Found by review (Codex and agy) of the XML editor's dialog:
- A busy or moved VM made the page reload and the edit was lost. The
dialog's requests now get these answers as JSON (X-Requested-With, as the
create page's flavor forms) and show them; any other failure says the
edit is still in the editor.
- libvirt finds the VM to define by the XML's UUID: another VM's UUID
overwrote that VM past its lock, a new or missing one added a VM. The
XML must keep this VM's UUID; a malformed one is refused with the
parser's error, as on the create page.
- Save sends the XML whose diff the dialog shows, also when two previews
answer out of order; a failed save keeps the diff and Save.
- The edited XML is defined with the line breaks libvirt gave, not the
form's CRLF, and the VM's state is read before the definition, so a
failed read cannot turn a saved change into an error.
The XML editor of a running or paused VM only showed its XML. It now
shows the persistent definition, the one the VM starts with next (with
secrets, like the other edit forms), and Change saves it through the same
review dialog; the running VM keeps its live configuration until it
restarts. The tab and the saved message say so. The check against a
definition changed since the editor was opened covers this case too.
Change used to define the edited XML at once. It now opens a dialog with
the lines the edit changes in the VM's definition, and Save there defines
it. A definition libvirt refuses comes back to the dialog with the reason,
and the edit stays in the editor; before, the page reloaded with the
current XML and the edit was lost. A definition that changed since the
editor was opened (another tab, a disk added) is not overwritten: the
dialog asks to reload. An unchanged XML has nothing to save.
change_xml answers the dialog with JSON; the editor's script moves to
static/js/xml_editor.js.
sortable.js 0.8.0 (abandoned since 2014) sorted only a table's first
body: in the grouped VM list the first host's row moved among its VMs
and the other hosts never sorted, and memory sorted as text ("1024 MB"
before "512 MB"). tablesort 5.7.1 sorts each table body on its own and
keeps a row marked data-sort-method="none" in place, so hosts stay in
name order with their row on top and their VMs sort below it. Memory,
vCPUs and volume sizes sort by their numbers (data-sort). Headers are
reachable by keyboard and carry aria-sort.
base.html loads tablesort for every table marked data-sortable; the
per-page sortable includes and sortable-theme-bootstrap.css go, so these
tables take the theme's table style like the others. The grouped list's
row numbers go (meaningless once sorted). The VM owners and VM logs
tables were marked sortable but never loaded the script; they stay
unsorted.
The search box floats right of the short Volumes heading and hung
below it, so the table beside it shrank and its headers met the box.
The heading's wrapper now clears the float.
The list gets a state selector beside the search box. The search now
matches plain text in any case (a '(' no longer breaks it), and in the
grouped list a host's name shows all of its VMs while a host row shows
only while one of its VMs does. The filter moves to
static/js/instance_filter.js with a Node test; the other lists keep
filter-table.js. Collapsing a host now sets a class on its table body
instead of each row's display, so a collapsed host and the filter no
longer undo each other.
From the Codex and agy review of the VM audit log before v3.2.0:
- Resolving an uncertain migration wrote its row without the VM's UUID,
because the migrate job writes no entries of its own; the resolution now
takes the UUID from the job's intent, so the VM's Logs tab shows it.
- A console keymap or listen address the VM could not take (no graphics
device, or a console on a socket) was logged as done although nothing
changed; it now writes no row, as an edit that changes nothing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Operations on a VM now write their rows the way the admin audit does: one
row per operation, done or failed with a safe reason, with the user, IP and
host. Each row names the VM ("VM web-1: delete snapshot snap1") and carries
its UUID in the new Logs.instance_uuid column.
- audit_vm and failed_vm wrap audit() and failed() for a VM. A failure the
view turns into a message is caught outside the block, so its failed row
is written; busy claims, permission and quota refusals and invalid input
write nothing. No password, key or XML goes into a row.
- All VM page operations move from addlogmsg to audit_vm, and addlogmsg
goes. QoS changes and defining a VM from XML are logged now; a gstfsd
error reply (root password, SSH key) is a failed row, not a done one.
- Partial results are rows of their own: the VM delete, then each disk
delete; the disk detach, then the volume delete; the migration, then a
failed autostart restore, NVRAM removal or record on the target.
- Migration rows use the compute name as the host column, as every other
row does.
- VM job rows (create, clone, snapshots) and their resolutions carry the
VM's UUID; snapshot job intents now record it. The owner rows of the VM
page and the accounts pages, and the ownership system rows, carry it too.
- The Logs tab loads vm_logs/<pk>/ and shows the rows with the VM's UUID,
so a rename keeps the history, two VMs with one name are told apart and
a VM on two computes is one VM. It gains a Result column. Rows from
before this change have no UUID and stay on the Logs page only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The machine types, volumes, templates and disk buses load when their
parent changes. A slow answer for an earlier choice could arrive last and
fill the list for the newer one, and while a list loaded it still held
the previous choice's items, which could be submitted.
Each list now empties when its load starts, and a newer load aborts the
older request, so only the answer for the current choice fills it. Chosen
volumes stay: they are found by name in any pool.
Fixes#177
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The dialog looped over every compute and skipped the unreachable ones,
so its "No compute is reachable." message showed only when there were no
computes at all. With hosts that were all offline it was an empty list.
The index view now gives the dialog the reachable computes, and the
template lists those, so the message covers that case.
Fixes#176
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the machine step, "Or define it from XML" lost the definition when it
was refused: a taken name re-rendered the page with an empty, collapsed
editor; malformed XML went on to libvirt, and libvirt's refusal sent the
browser back to an empty page with the reason at the top.
All three refusals now render the page with the section open, the
submitted XML in the editor (and in the field it posts, so it can be sent
again unchanged), and the reason above the editor, which takes the focus.
Fixes#174
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The create page's flavor dialog posted its forms the normal way, and the
server sent a fresh page back: the VM's name, sizes, disk choices and MAC
edits were lost, and a refused flavor left the page for a bare form.
The dialog's forms are now posted from the page. The flavor views answer
such a post with the presets and the dialog body, rendered from the same
includes as the page, and the script swaps them in: a refused flavor shows
its errors in the dialog, and the focus stays in it. A post without the
script, or one whose request fails, still goes the normal way.
Fixes#173
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The step ran `docker compose -p webvirtcloud up -d`. The other Docker
steps let compose name the project after the directory, so in a clone
under another directory name this command started a second deployment
with its own volumes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pip-audit audited the whole CI environment, so on Python 3.10 it failed
on filelock 4.1.0, a dependency of pip-audit itself: six CVEs published
on 2026-10-23 are fixed only in filelock 4.2.1, which needs Python 3.11.
No installation gets filelock. The step now audits conf/requirements.txt
and conf/requirements-ldap.txt, resolved for the leg's Python, which is
what the audit is for.
The create form and the API took 0 or a negative number for vCPUs,
memory and a new disk's size: the API queued the job (202) and it
failed later in libvirt; the page did the same, its formnovalidate
skipping the browser's min. The create model's fields now refuse
values below 1, so the form shows the error beside its field and the
API answers 400 naming it.
The form also checks the MAC addresses as the API and the job do (one
per NIC at most, each a MAC address), so a bad one is an error on the
form instead of a job that fails.
Closes#175
The live suite run against v3.1.0 on two Vagrant computes (TCP and SSH,
TLS, a socket inside compute1) found two tests that no longer held, and
nothing else:
- A SATA disk added to a running VM is refused by libvirt; since
90c148d a refused form goes back to its page with the reason instead
of a 500. The test expected the 500; it now expects the redirect and
the message, and still checks that neither definition changed.
- The NVRAM copy test built its libvirtError("define failed") and
matched that text, but libvirt-python gives a new libvirtError the
message of the last error its thread recorded: in the socket run a
connection of an earlier live test was closed by then ("client socket
is closed"). The test now checks that its own error object is raised.
The create page had three tabs (flavor, custom, template), each with its
own copy of the same create form; every flavor rendered one more copy in
a dialog, and fixes had to be made in each copy. All of them posted the
same form to the same job, differing only in where the disk comes from.
The page is now one form in sections: name and size, disk, networks and
an Advanced section. The disk section offers three sources as radio
cards (a new disk, existing volumes, a copy of a template); only the
chosen source's fields are shown, and the others are disabled, so they
are neither sent nor validated. A template's copy goes into the
template's pool unless another is picked. Flavors are presets that fill
vCPU, memory and disk size and pick a new disk; they are added and
deleted in a dialog outside the form. Every source uses the
per-interface network rows, so a new disk no longer requires a MAC:
libvirt gives an interface without one its address, as it did for the
other sources. A refused form comes back with its source, volumes,
template and networks, and the focus on its first error. Without a
storage pool or a network the page shows why instead of the form, and a
host that cannot be read is a message, not a 500.
The host dialog and the machine page share the form's look: hosts are
rows with their CPU and memory use, the machine page is a card with
Cancel/Next and the XML way is a collapsible section. All three steps
show the same step indicator, done steps linking back.
The create job refuses more than one disk source (the page sends one; a
crafted request could send two, and the job would have used the first).
A field with help text is described by its help and its error.
Frontend review fixes: the presets and the disclosure buttons show a
focus ring for keyboard users; a disclosure spans its card and its
chevron turns while open; a preset says it picks a new disk; numeric
fields bring a numeric keyboard and MAC fields no autocorrection; the
machine page's Next waits for a machine type. Tom Select's control
keeps the text colour of the select it replaces: its own fixed colour
made typed text invisible on dark Bootswatch themes, on every page that
uses it.
The floor applies to every CI leg; the PostgreSQL leg skips more tests
and measures 85.3% (SQLite 86%), so a floor of 85 left it 0.3 points.
84 keeps the one point of room the floor is meant to have.
Django's login form gives the username its max_length after the field
is built, so a longer username was not refused and reached
authentication. django-axes then stored it in its 255-character column,
which PostgreSQL refuses: a login with a 300-character username was a
500 (SQLite does not enforce the length). Both login forms, with and
without OTP, now check the length, so such a username is a form error
and never reaches authentication, the lockout or the Logs page.
This also turns the PostgreSQL leg of CI green again: the S-16 test
from #167 hit exactly this.
The suite grew from 848 to 1376 tests since the floor was set; the same
coverage run CI does measures 86% (was 77%). The floor keeps one point
of room, as before.
Two computes decide the owners of the same UUID: the one the VM left
buries them in a tombstone (or a compute delete does), the one it went
to restores them. On PostgreSQL the two ran at once: the restore still
saw the old row of the uncommitted bury, skipped, and the VM ended up
without owners while the tombstone stayed. SQLite was never affected:
its IMMEDIATE transactions take the write lock at the start.
The reconcile and the compute delete now take one transaction-level
advisory lock (pg_advisory_xact_lock) when their database step starts,
so ownership decisions run one after another on PostgreSQL as they do
on SQLite. Reading libvirt stays outside the lock.
Failed logins are logged now, so S-16 applies: what a user types as a
username must not break a row or forge another record. The Logs page
shows each value in its own cell, escaped, and the writer cuts the
username to the 150-character field (Django's login form does not stop
a longer one). The file-log line written when a row cannot be saved
escaped only "\n"; it now escapes "\r" too, so a CR LF in a username
stays one line.
Follow-ups from the review of the audit log layers:
- Blocking a user who is already blocked, or unblocking one who is
not, changes nothing and now writes no row, like any other edit
that changes nothing.
- write_chunk takes the request instead of the user and an optional
request; the view always had one, so the branch only tests used
is gone and every chunk 0 goes through the same audited submit.
- Tests: a busy clone creates no job and its row carries the page's
message; a later chunk that fails writes no submit row; libvirt
refusing a network or pool delete is a failed row.
The create forms of networks and storage pools checked the new name
against the host's list of networks or pools, but that list holds one
dict per object, so the name never matched and the check never fired;
libvirt refused the duplicate with its own wording. The check now
compares names: the page shows its own message, nothing is sent to
libvirt, and the Logs page gets a failed row "...: add: name already
in use".
With "Multiple Owner for VM" off, the ownership form counted the
ownership being edited as another owner, so changing the only owner's
rights (change, delete, console) always failed with "Instance owned by
another user". The check now leaves out the ownership being edited; a
second owner is still refused.
The Logs page now records every change an admin makes to a compute's
libvirt objects, with the compute's name in the host column:
- networks: add, start, stop, delete, autostart, DHCP range, fixed
addresses, QoS and XML edits;
- storage pools: add, start, stop, delete, autostart, from web and
API; volumes: add and delete (web and API), clone and ISO upload;
- host interfaces: add, start, stop, delete;
- secrets: add, set value (never the value), delete;
- network filters: add, clone, delete, XML edits, rules and
references.
A refused or failed change is a failed row with its reason (libvirt
text through safe_text). The volume clone and upload jobs write their
row when they end; a refused submission is a failed row at once. A
volume row names the volume libvirt created (a dir pool adds .qcow2 or
.img). Volume create or delete through the API on an inactive pool
(409) is a failed row.
Network filters: the rows read "Network filter f1: add" instead of
"f1 network filter is created", and their host is the compute's name
instead of its hostname. A duplicate name or UUID no longer raises a
bare exception, and a filter XML that cannot be parsed no longer ends
in UnboundLocalError.
The Logs page now records the panel's own records too: users (add,
change with the changed fields, delete, block, unblock, an admin's
password set, a user's own password change including the first-login
one, own profile edits), groups with their members and permissions,
VM owners from the accounts pages and from the VM page, OTP QR code
mails, flavors from web and API, app settings and the theme,
removed-VM records and SSH keys. No row carries a password or a key.
- A user is created in one transaction: when a VM named in
NEW_USER_DEFAULT_INSTANCES is missing, no half-made user stays.
- The OTP mail checks the email address before it creates a device;
without one it writes a failed row and makes no device.
- The VM page's owner rows read "VM web-1 owner bob: add" and
"...: remove" instead of "Add owner: bob" and "Delete owner: 17"; a
second owner refused by the single-owner setting is a failed row.
Owner rows keep the VM's host and name, so the VM's log tab shows
them.
- A setting or theme posted with its current value is no change and
writes no row; the old free-text setting rows are gone.
Host-side objects (networks, storage, interfaces, secrets, filters)
follow in the next layer.
The Logs page shows who added, changed or deleted a compute, from web
or API, and every login, failed login, lockout and logout, with the
client address and a Result column (Done, Failed, Unknown for older
rows). A failed change is recorded with its reason and the error still
reaches the user; a refused one is recorded too.
- logs.audit.write saves each row in its own savepoint and never
raises, so a log write cannot break or undo the action it records.
The audit() context manager writes one row when the change ends.
- Logs gains success and ip; user, host and instance are wider and
date is indexed. The page orders by date then id.
- A compute edit names the changed fields; an edit that changes
nothing writes no row, and an empty password field is no change.
- A compute's VMs leaving the panel get a system row each: owners
kept on the Removed VMs page, moved to another compute, restored,
left without owners, or the removed record expired.
- Job rows carry the result. A job's log entry that cannot be
computed is logged and skipped instead of rolling back the finish
(test_service.py changes this on purpose).
- Logins are recorded by signal receivers; CustomLoginView is removed
and the plain LoginView is used. A wrong OTP code is a failed login,
counted by django-axes toward the lockout.
Panel records and host-side objects follow in the next layers.
Only an administrator chose whether the VNC console fits the remote
screen to the window (Settings, or ?scale= in the URL). A Scale switch
in the console menu now flips between fitted and 1:1 with scrollbars,
starting from that setting. On a phone, where noVNC takes the touch
screen as the mouse and the 1:1 view cannot be scrolled, it is the way
to see the whole screen.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>