Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
a3e7b0d
feat(images): import from a published catalogue, and take uploads a c…
ericwang401 Sep 20, 2026
46bd159
feat(images): browse the catalogue, and upload a disk with progress a…
ericwang401 Sep 20, 2026
f3b1611
fix(servers): re-check address reachability when a server is re-homed
ericwang401 Sep 20, 2026
7536ca9
style(images): satisfy phpstan and pint on the new image code
ericwang401 Sep 20, 2026
2db27d1
feat(servers): migrate a server between nodes, and adopt an existing …
ericwang401 Sep 20, 2026
cd20801
fix(images): never advertise a chunk size PHP would refuse
ericwang401 Sep 20, 2026
c4bf41e
docs: design per-server permissions, guest accounts and admin roles
ericwang401 Sep 20, 2026
28d1147
fix(api): cover every application API resource in the token ability list
ericwang401 Sep 20, 2026
0dac675
feat(admin): migrate dialog and an unmanaged-guests screen
ericwang401 Sep 20, 2026
466f3a7
feat(permissions): sub-users, guest accounts and fine-grained admin r…
ericwang401 Sep 20, 2026
1d95d36
fix(servers): close three races between migration and the placement poll
ericwang401 Sep 20, 2026
92f39c0
test(ui): add a frame-by-frame interaction verification harness
ericwang401 Sep 20, 2026
57ba832
feat(permissions): sharing, roles and guest separation in the UI
ericwang401 Sep 20, 2026
eb3097c
Merge branch 'worktree-agent-a8d8dbaded3de037a' into integration/v5-w…
ericwang401 Sep 20, 2026
a663174
fix(tests): pin the cache, queue and session drivers the suite asks for
ericwang401 Sep 20, 2026
3ef4042
Merge branch 'worktree-agent-aef48a99676508da4' into integration/v5-w…
ericwang401 Sep 20, 2026
327a3c9
Merge branch 'worktree-agent-a212774daa2f3dc72' into integration/v5-w…
ericwang401 Sep 20, 2026
ab1a4ca
fix(permissions): classify the adoptable-guests routes in both vocabu…
ericwang401 Sep 20, 2026
b59535e
fix(roles): stop the Grants column collapsing to its longest word
ericwang401 Sep 20, 2026
e59ae62
test(ui): add verification probes for the new image, role and sharing…
ericwang401 Sep 20, 2026
8807d21
fix(images): allocate the TPM state volume a Windows image asks for
ericwang401 Sep 20, 2026
446da8e
fix(images): store the build time and stop inventing a version
ericwang401 Sep 20, 2026
aac10cb
feat(migration): move a guest between clusters over Anchor
ericwang401 Sep 20, 2026
6d5b356
test(migration): pin the Anchor transport's ordering and every way it…
ericwang401 Sep 20, 2026
757f249
feat(ui): say what a destination costs on the row that picks it
ericwang401 Sep 20, 2026
b15431d
docs(migration): reconcile the Anchor contract with what shipped
ericwang401 Sep 20, 2026
0bedcdb
Merge branch 'worktree-agent-ac8c114894a0fd8e9' into integration/v5-w…
ericwang401 Sep 20, 2026
a391ec4
fix(migration): tell the destination not to template the migrated guest
ericwang401 Sep 20, 2026
f8da095
test(migration): drive a real adopt-and-migrate against two live nodes
ericwang401 Sep 20, 2026
1549815
fix(migration): point discard at the route the agent actually serves
ericwang401 Sep 20, 2026
2a50d77
fix(migration): stop a failed cleanup undoing a successful migration
ericwang401 Sep 20, 2026
8f8b268
fix(migration): actually destroy the guest a failed migration leaves …
ericwang401 Sep 20, 2026
770b637
style: apply pint to the migration protocol and its test
ericwang401 Sep 20, 2026
a9adc7b
fix(tests): configure the mail settings the invite actually reads
ericwang401 Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/public/hot
/public/storage
/storage/*.key
/storage/uiverify
/vendor
.env
.env.backup
Expand Down
66 changes: 66 additions & 0 deletions .sbx/dev/uiverify/drag.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Drag helpers.
*
* Two different problems:
*
* - Raw pointer handlers (the avatar cropper) only need a down/move/up with
* enough intermediate points that the handler sees real deltas. A single
* jump from A to B produces one move event and hides any lag.
* - dnd-kit's PointerSensor has an activation constraint, so a drag that
* starts and ends in one step never activates at all: the test "passes"
* while nothing moved. It needs a small priming move past the threshold,
* then steps, then a settle before release.
*
* Steps are deliberate rather than Playwright's `steps` option because the
* frame recorder wants time to pass between them: an instant interpolation
* paints one frame and defeats the point of recording.
*/

export const dragSmooth = async (page, from, to, { steps = 24, holdMs = 12, settleMs = 260 } = {}) => {
await page.mouse.move(from.x, from.y)
await page.mouse.down()
await page.waitForTimeout(40)

for (let i = 1; i <= steps; i++) {
const t = i / steps
// Ease in-out, so the middle of the gesture moves fastest; a dropped
// frame shows up as a visible gap rather than an even stutter.
const e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
await page.mouse.move(from.x + (to.x - from.x) * e, from.y + (to.y - from.y) * e)
await page.waitForTimeout(holdMs)
}

await page.waitForTimeout(settleMs)
await page.mouse.up()
await page.waitForTimeout(settleMs)
}

/* dnd-kit: prime past the activation constraint before the real travel. */
export const dragSortable = async (page, handle, to, opts = {}) => {
const box = await handle.boundingBox()
if (!box) throw new Error('drag handle has no bounding box')

const from = { x: box.x + box.width / 2, y: box.y + box.height / 2 }

await page.mouse.move(from.x, from.y)
await page.mouse.down()
// Past the default 8px activation distance, in small steps.
for (const d of [3, 6, 10, 14]) {
await page.mouse.move(from.x, from.y + d)
await page.waitForTimeout(25)
}

await dragSmoothTail(page, { x: from.x, y: from.y + 14 }, to, opts)
}

const dragSmoothTail = async (page, from, to, { steps = 20, holdMs = 14, settleMs = 300 } = {}) => {
for (let i = 1; i <= steps; i++) {
const t = i / steps
await page.mouse.move(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t)
await page.waitForTimeout(holdMs)
}

await page.waitForTimeout(settleMs)
await page.mouse.up()
await page.waitForTimeout(settleMs)
}
153 changes: 153 additions & 0 deletions .sbx/dev/uiverify/filmstrip.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Frame-by-frame capture for UI interaction verification.
*
* A single still cannot show a sub-second glitch: a backdrop that flashes, a
* layout that jumps one frame before settling, a skeleton that paints at the
* wrong size, a drag whose ghost lags the cursor. This records the real frames
* the compositor produced and lays them out as a contact sheet.
*
* CDP's screencast emits a frame only when the page actually changes, so the
* frame count is itself a signal: a "simple" hover that produces 30 frames is
* repainting more than it should, and two identical frames either side of a
* different one is a flash.
*
* No ffmpeg. The contact sheet is composed in the browser from the captured
* JPEGs and screenshotted, so the only dependency is the pinned Playwright the
* dev kit already installs at /opt/sbx-e2e.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'

/*
* Record every frame the page paints while `action` runs.
*
* `settleAfter` keeps recording past the end of the action, because the glitch
* worth catching is usually the settle, not the trigger.
*/
export const record = async (
page,
action,
{ quality = 70, maxWidth = 1440, maxHeight = 1000, settleAfter = 600 } = {}
) => {
const client = await page.context().newCDPSession(page)
const frames = []

client.on('Page.screencastFrame', async ({ data, metadata, sessionId }) => {
frames.push({ data, timestamp: metadata.timestamp })

// Unacked frames stop the stream, so this must not throw on a page
// that navigated out from under us mid-capture.
try {
await client.send('Page.screencastFrameAck', { sessionId })
} catch {
/* session gone; the frames already collected are still good */
}
})

await client.send('Page.startScreencast', {
format: 'jpeg',
quality,
maxWidth,
maxHeight,
everyNthFrame: 1,
})

// A frame at t0 with no change to trigger it, so a strip always has a
// before-state to compare against.
await page.waitForTimeout(120)

let actionError = null

try {
await action()
} catch (e) {
actionError = e
}

await page.waitForTimeout(settleAfter)
await client.send('Page.stopScreencast').catch(() => {})
await client.detach().catch(() => {})

if (actionError) throw actionError

const t0 = frames.length ? frames[0].timestamp : 0

return frames.map((f, i) => ({
index: i,
data: f.data,
ms: Math.round((f.timestamp - t0) * 1000),
}))
}

/*
* Lay frames out as a labelled grid and screenshot it.
*
* Composed in a page rather than with an image tool so that reviewing a strip
* costs one image instead of forty, and the elapsed-ms label sits on the frame
* it belongs to.
*/
export const contactSheet = async (
context,
frames,
{ outPath, columns = 5, cellWidth = 320, title = '' }
) => {
if (!frames.length) throw new Error(`no frames captured for ${title || outPath}`)

await mkdir(path.dirname(outPath), { recursive: true })

const page = await context.newPage()

try {
const html = `<!doctype html><meta charset="utf-8">
<style>
:root { color-scheme: light }
body { margin: 0; background: #0b0b0c; font: 12px ui-monospace, monospace; color: #e7e7ea }
h1 { font: 600 14px ui-sans-serif, system-ui; margin: 12px 16px 4px }
.meta { margin: 0 16px 12px; color: #9b9ba3 }
.grid { display: grid; grid-template-columns: repeat(${columns}, ${cellWidth}px); gap: 8px; padding: 0 16px 16px }
figure { margin: 0 }
img { width: ${cellWidth}px; display: block; border: 1px solid #2a2a30; background: #fff }
figcaption { padding: 3px 2px; color: #9b9ba3 }
/* A frame that repeats its predecessor is the tell for a flash: mark the
transitions so the eye lands on them first. */
.jump figcaption { color: #ffd479 }
</style>
<h1>${escapeHtml(title)}</h1>
<div class="meta">${frames.length} frames · ${frames[frames.length - 1].ms} ms total</div>
<div class="grid">
${frames
.map(
f => `<figure><img src="data:image/jpeg;base64,${f.data}">
<figcaption>#${f.index} · +${f.ms}ms</figcaption></figure>`
)
.join('\n')}
</div>`

await page.setViewportSize({
width: columns * (cellWidth + 8) + 32,
height: 1000,
})
await page.setContent(html, { waitUntil: 'load' })
await page.screenshot({ path: outPath, fullPage: true })
} finally {
await page.close()
}

return outPath
}

/* Write the raw frames too, so a suspicious pair can be viewed at full size. */
export const dumpFrames = async (frames, dir, pick = []) => {
await mkdir(dir, { recursive: true })

const wanted = pick.length ? frames.filter(f => pick.includes(f.index)) : frames

for (const f of wanted) {
await writeFile(path.join(dir, `frame-${String(f.index).padStart(3, '0')}.jpg`), Buffer.from(f.data, 'base64'))
}

return wanted.length
}

const escapeHtml = s =>
String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
119 changes: 119 additions & 0 deletions .sbx/dev/uiverify/live-migrate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Live end-to-end: adopt a real guest off one Proxmox node, then migrate it to
* the other over Anchor. Drives the panel's own HTTP API with a real session,
* so nothing here is mocked.
*/
import { BASE, launch, newContext, login } from '/opt/sbx-e2e/browser.mjs'

// Either direction: the reverse run is the same code with these swapped.
const SOURCE_NODE = Number(process.env.SOURCE_NODE ?? 3)
const TARGET_NODE = Number(process.env.TARGET_NODE ?? 4)
const VMID = 9200
const KNOWN_SERVER = process.env.SERVER_UUID || null

const browser = await launch()
const ctx = await newContext(browser)
const page = await login(ctx, { email: 'admin@example.com', password: 'Zzz!98765' })

const api = async (method, path, body) =>
page.evaluate(
async ([m, p, b]) => {
const xsrf = decodeURIComponent((document.cookie.match(/XSRF-TOKEN=([^;]+)/) ?? [])[1] ?? '')
const res = await fetch(p, {
method: m,
headers: {
'X-XSRF-TOKEN': xsrf,
Accept: 'application/json',
...(b ? { 'Content-Type': 'application/json' } : {}),
},
...(b ? { body: JSON.stringify(b) } : {}),
})
const text = await res.text()

return { status: res.status, body: text }
},
[method, path, body ?? null]
)

console.log('== 0. what storages does the panel see on each node? ==')
for (const n of [SOURCE_NODE, TARGET_NODE]) {
const live = await api('GET', `/api/admin/nodes/${n}/storages/proxmox`)
console.log(` node ${n} live:`, live.status, live.body.slice(0, 400))
const known = await api('GET', `/api/admin/nodes/${n}/storages`)
console.log(` node ${n} known:`, known.status, known.body.slice(0, 300))
}

console.log('\n== 0b. persist the `local` storage on both nodes ==')
for (const n of [SOURCE_NODE, TARGET_NODE]) {
// `size` is written in BYTES: StorageSizeCast stores mebibytes and reads
// bytes back, so a value already in MiB is floored to 0 and fails min:1.
const live2 = await api('GET', `/api/admin/nodes/${n}/storages/proxmox`)
let bytes = 10 * 1024 ** 3
try {
const total = JSON.parse(live2.body).data.find(x => x.name === 'local')?.total
if (total) bytes = total
} catch {
/* fall back to the default above */
}

const made = await api('POST', `/api/admin/nodes/${n}/storages`, { name: 'local', size: bytes })
console.log(` node ${n} create (${bytes} bytes):`, made.status, made.body.slice(0, 250))
}

console.log('\n== 1. is the guest visible as adoptable? ==')
const look = await api('GET', `/api/admin/nodes/${SOURCE_NODE}/adoptable-guests/${VMID}`)
console.log(look.status, look.body.slice(0, 600))

console.log('\n== 2. adopt it ==')
const adopt = await api('POST', `/api/admin/nodes/${SOURCE_NODE}/adoptable-guests/${VMID}`, { user_id: 2 })
console.log(adopt.status, adopt.body.slice(0, 800))

let serverUuid = null
try {
const d = JSON.parse(adopt.body)
serverUuid = d?.data?.uuid ?? d?.uuid ?? null
} catch {
/* printed above */
}

// A second run refuses, correctly, because the guest is already adopted. Find
// the server that refusal is pointing at rather than treating it as a failure.
if (!serverUuid && KNOWN_SERVER) {
serverUuid = KNOWN_SERVER
console.log(' already adopted; using the known server uuid')
}

if (!serverUuid) {
const list = await api('GET', '/api/admin/servers?per_page=100')
try {
const rows = JSON.parse(list.body)?.data ?? []
serverUuid = rows.find(r => r.vmid === VMID)?.uuid ?? null
console.log(' already adopted; reusing existing server')
} catch {
console.log(' could not list servers:', list.status, list.body.slice(0, 200))
}
}

console.log('server uuid:', serverUuid)

if (!serverUuid) {
process.exit(0)
}

console.log('\n== 3. migration plan ==')
const plan = await api('GET', `/api/admin/servers/${serverUuid}/migration`)
console.log(plan.status, plan.body.slice(0, 1200))

console.log('\n== 4. plan for the specific destination ==')
const planTo = await api('GET', `/api/admin/servers/${serverUuid}/migration/${TARGET_NODE}`)
console.log(planTo.status, planTo.body.slice(0, 1200))


console.log('\n== 5. COMMIT the migration ==')
const go = await api('POST', `/api/admin/servers/${serverUuid}/migration`, {
node_id: TARGET_NODE,
acknowledge_address_change: true,
})
console.log(go.status, go.body.slice(0, 1200))

await browser.close()
17 changes: 17 additions & 0 deletions .sbx/dev/uiverify/probe-backfill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { BASE, launch, newContext, login } from '/opt/sbx-e2e/browser.mjs'
const browser = await launch()
const ctx = await newContext(browser)
const page = await login(ctx, { email: 'admin@example.com', password: 'Zzz!98765' })
await page.goto(BASE + '/admin/images', { waitUntil: 'networkidle' })
await page.waitForTimeout(800)
const res = await page.evaluate(async () => {
const xsrf = decodeURIComponent((document.cookie.match(/XSRF-TOKEN=([^;]+)/) ?? [])[1] ?? '')
const r = await fetch('/api/admin/images/registry/imports', {
method: 'POST',
headers: { 'X-XSRF-TOKEN': xsrf, 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ templates: ['debian-12-amd64'] }),
})
return { status: r.status, body: (await r.text()).slice(0, 300) }
})
console.log(JSON.stringify(res, null, 1))
await browser.close()
18 changes: 18 additions & 0 deletions .sbx/dev/uiverify/probe-builds.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { BASE, launch, newContext, login, collectErrors } from '/opt/sbx-e2e/browser.mjs'
const OUT = '/Users/eric/Documents/GitHub/panel/storage/uiverify/out'
const browser = await launch()
const ctx = await newContext(browser, { viewport: { width: 1600, height: 1000 }, deviceScaleFactor: 1 })
const page = await login(ctx, { email: 'admin@example.com', password: 'Zzz!98765' })
const errors = collectErrors(page)

await page.goto(BASE + '/admin/images', { waitUntil: 'networkidle' })
await page.waitForTimeout(900)
await page.getByRole('button', { name: /catalog|catalogue|browse/i }).first().click()
await page.waitForTimeout(3000)
const rows = await page.evaluate(() => [...document.querySelectorAll('[role="dialog"] [class*="ItemDescription"], [role="dialog"] p')].map(e => e.textContent.trim()).filter(t => /transfer/.test(t)).slice(0, 4))
console.log('catalogue rows:'); rows.forEach(r => console.log(' ', r))
await page.screenshot({ path: OUT + '/builds-catalogue.png' })
await page.keyboard.press('Escape')
await page.waitForTimeout(800)
console.log('errors:', JSON.stringify(errors.filter(e => !/401|403/.test(e))))
await browser.close()
Loading
Loading