diff --git a/mock/stats-stub/grid.js b/mock/stats-stub/grid.js new file mode 100644 index 00000000..274ea521 --- /dev/null +++ b/mock/stats-stub/grid.js @@ -0,0 +1,143 @@ +// What the dashboard asks of a box to draw its home page without one: the +// virtual machine module (in the shapes of CasaOS-VirtManagement's codegen +// HostOK, VMListOK and VMOK), the app grid (WebAppGridItem) and the few core +// calls the shell reads without a guard. A pure `handleGrid(request, state)`: +// +// request: { method, path, body } -> { status, body }, or null when the path is not one of these +// state: newGridState() what was put in custom storage + +const GIB = 1073741824 + +const HOST = { + libvirt: 'available', + libvirt_version: '9.0.0', + kvm: true, + uefi: true, + cpus: 8, + memory_mib: 16384, + network: 'casaos', + network_gateway: '10.8.0.1', + network_subnet: '10.8.0.0/24', + bridges: [], + links: ['eth0'], + storage_path: '/DATA/VM', + storage_free_bytes: 500 * GIB, +} + +function aVm({ id, name, title, state, os, ip }) { + return { + id, + name, + title, + state, + os, + managed: true, + console: true, + vcpus: 2, + memory_mib: 2048, + firmware: 'uefi', + pending_restart: false, + network: { mode: 'casaos', name: 'casaos', casaos: true, fixed: true, ip, mac: '52:54:00:12:34:56' }, + disks: [{ target: 'vda', path: `/DATA/VM/${name}.qcow2`, managed: true, boot: true, capacity_bytes: 32 * GIB, allocation_bytes: 4 * GIB }], + snapshots: [], + ports: [], + usb: [], + cdrom: '', + } +} + +const VMS = [ + aVm({ id: '11111111-aaaa-4aaa-8aaa-111111111111', name: '1a2b3c4d', title: 'Debian', state: 'running', os: 'debian', ip: '10.8.0.10' }), + aVm({ id: '22222222-bbbb-4bbb-8bbb-222222222222', name: '5e6f7a8b', title: 'Windows 11', state: 'shut-off', os: 'windows', ip: '10.8.0.11' }), + aVm({ id: '33333333-cccc-4ccc-8ccc-333333333333', name: '9c0d1e2f', title: '', state: 'paused', os: '', ip: '10.8.0.12' }), +] + +function anApp(name, title, port, image) { + return { + name, + title: { en_us: title }, + icon: '', + status: 'running', + app_type: 'v2app', + author_type: 'by_casaos', + compose_project: name, + store_app_id: name, + scheme: 'http', + hostname: '', + port, + index: '/', + image, + is_uncontrolled: false, + update_available: false, + } +} + +const APPS = [ + anApp('jellyfin', 'Jellyfin', '8096', 'linuxserver/jellyfin:latest'), + anApp('nextcloud', 'Nextcloud', '10081', 'nextcloud:latest'), + anApp('immich', 'Immich', '2283', 'ghcr.io/immich-app/immich-server:release'), + anApp('plex', 'Plex', '32400', 'plexinc/pms-docker:latest'), +] + +const UTILIZATION = { + cpu: { percent: 12, num: 4, temperature: 45, model: 'stub', power: { timestamp: '0', value: '0' } }, + mem: { total: 8 * GIB, available: 5 * GIB, used: 3 * GIB, free: 5 * GIB, usedPercent: 37 }, + sys_disk: { avail: 200 * GIB, health: true, size: 256 * GIB, used: 56 * GIB }, + sys_usb: [], + net: [{ name: 'eth0', bytesSent: 0, bytesRecv: 0, packetsSent: 0, packetsRecv: 0, time: 0 }], +} + +// the core's envelope +const core = data => ({ status: 200, body: { success: 200, message: 'ok', data } }) + +const CORE_GETS = { + '/v1/users/status': () => core({ initialized: true, key: '' }), + '/v1/users/current': () => core({ id: 1, username: 'casaos', nickname: 'casaos', role: 'admin', avatar: '', description: '' }), + '/v1/sys/version': () => core({ current_version: '0.4.15', need_update: false, version: { version: '0.4.15', change_log: '' } }), + '/v1/sys/hardware': () => core({ drive_model: 'stub', arch: 'amd64' }), + '/v1/sys/utilization': () => core(UTILIZATION), + '/v1/sys/telemetry': () => core({ enabled: false }), + '/v1/gateway/port': () => core('80'), + '/v1/usb/usb-auto-mount': () => core('False'), +} + +const CUSTOM = /^\/v1\/users\/current\/custom\/([^/]+)$/ +const ONE_VM = /^\/v2\/virt_management\/vms\/([^/]+)$/ + +export function newGridState() { + return { custom: new Map() } +} + +export function handleGrid({ method = 'GET', path = '', body = null } = {}, state = newGridState()) { + const verb = method.toUpperCase() + const custom = path.match(CUSTOM) + if (custom) { + const key = decodeURIComponent(custom[1]) + if (verb === 'POST') { + state.custom.set(key, body) + return core(body) + } + // what was never put is an empty string, as the core answers + return verb === 'GET' ? core(state.custom.has(key) ? state.custom.get(key) : '') : null + } + if (verb !== 'GET') { + return null + } + if (CORE_GETS[path]) { + return CORE_GETS[path]() + } + switch (path) { + case '/v2/virt_management/host': + return { status: 200, body: { data: HOST } } + case '/v2/virt_management/vms': + return { status: 200, body: { data: VMS } } + case '/v2/app_management/web/appgrid': + return { status: 200, body: { data: APPS } } + } + const one = path.match(ONE_VM) + if (one) { + const vm = VMS.find(candidate => candidate.id === decodeURIComponent(one[1])) + return vm ? { status: 200, body: { data: vm } } : { status: 404, body: { message: 'vm not found' } } + } + return null +} diff --git a/mock/stats-stub/grid.spec.js b/mock/stats-stub/grid.spec.js new file mode 100644 index 00000000..31beb75e --- /dev/null +++ b/mock/stats-stub/grid.spec.js @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { handleGrid, newGridState } from './grid.js' + +// What the dashboard asks of a box to draw its home page, its app grid and the +// virtual machine tiles, in the shapes the services answer with. +const ask = (method, path, body = null, state = newGridState()) => handleGrid({ method, path, body }, state) + +describe('the stub of the virtual machine module', () => { + it('answers the probe with a host that has libvirt', () => { + const { status, body } = ask('GET', '/v2/virt_management/host') + + expect(status).toBe(200) + expect(body.data).toMatchObject({ libvirt: 'available', kvm: true, network: 'casaos', storage_path: '/DATA/VM' }) + }) + + it('lists three VMs, running, shut off and paused, one with a hostile title', () => { + const { status, body } = ask('GET', '/v2/virt_management/vms') + + expect(status).toBe(200) + expect(body.data.map(vm => vm.state)).toEqual(['running', 'shut-off', 'paused']) + expect(body.data.map(vm => vm.title)).toContain('') + for (const vm of body.data) { + expect(vm.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + expect(vm).toMatchObject({ name: expect.stringMatching(/^[0-9a-f]{8}$/), managed: true, console: true }) + } + }) + + it('answers one VM by its id, and 404 for one it does not have', () => { + const [first] = ask('GET', '/v2/virt_management/vms').body.data + + expect(ask('GET', `/v2/virt_management/vms/${first.id}`).body.data).toEqual(first) + expect(ask('GET', '/v2/virt_management/vms/00000000-0000-0000-0000-000000000000')).toEqual({ status: 404, body: { message: 'vm not found' } }) + }) +}) + +describe('the stub of the app grid', () => { + it('lists four compose apps in the grid shape', () => { + const { status, body } = ask('GET', '/v2/app_management/web/appgrid') + + expect(status).toBe(200) + expect(body.data.map(app => app.title.en_us)).toEqual(['Jellyfin', 'Nextcloud', 'Immich', 'Plex']) + for (const app of body.data) { + expect(app).toMatchObject({ app_type: 'v2app', status: 'running', name: expect.any(String), hostname: '', port: expect.any(String) }) + } + }) +}) + +describe('the stub of the shell', () => { + it('says the box is set up and who is signed in, in the core envelope', () => { + expect(ask('GET', '/v1/users/status').body).toEqual({ success: 200, message: 'ok', data: { initialized: true, key: '' } }) + expect(ask('GET', '/v1/users/current').body.data).toMatchObject({ username: 'casaos' }) + }) + + it('keeps what is put in custom storage, and answers an empty string for what was never put', () => { + const state = newGridState() + + expect(ask('GET', '/v1/users/current/custom/app_order', null, state).body.data).toBe('') + ask('POST', '/v1/users/current/custom/app_order', { data: ['Files', 'Plex'] }, state) + + expect(ask('GET', '/v1/users/current/custom/app_order', null, state).body.data).toEqual({ data: ['Files', 'Plex'] }) + }) + + it('answers what the top bar and the widgets read', () => { + expect(ask('GET', '/v1/sys/version').body.data).toMatchObject({ need_update: false }) + expect(ask('GET', '/v1/sys/hardware').body.data).toMatchObject({ arch: 'amd64' }) + expect(ask('GET', '/v1/sys/utilization').body.data).toMatchObject({ cpu: { num: 4 }, mem: { total: expect.any(Number) } }) + expect(ask('GET', '/v1/sys/telemetry').body.data).toEqual({ enabled: false }) + expect(ask('GET', '/v1/gateway/port').body.data).toBe('80') + expect(ask('GET', '/v1/usb/usb-auto-mount').body.data).toBe('False') + }) + + it('leaves every other path to the caller', () => { + expect(ask('GET', '/v1/sys/nothing-here')).toBeNull() + expect(ask('DELETE', '/v2/virt_management/vms')).toBeNull() + expect(ask('GET', '/v2/virt_management/stats')).toBeNull() + }) +}) diff --git a/mock/stats-stub/server.mjs b/mock/stats-stub/server.mjs index 5e9cb4ad..6d7cf866 100644 --- a/mock/stats-stub/server.mjs +++ b/mock/stats-stub/server.mjs @@ -4,13 +4,15 @@ // // Answers /v1/sys/stats, /v2/app_management/stats and /v2/virt_management/stats from handlers.js, with series that end // at the current time (a live chart moves). With --upstream, every other request (and the WebSocket upgrade of the -// message bus) goes to that box; without it, other paths answer 404. +// message bus) goes to that box. Without it, grid.js answers what the home page asks of a box (the virtual machine +// module, the app grid, the shell's core calls), and every other path answers 404. // // GET /__stub/alerts?on=1 puts 2 active alerts on one container (on=0 takes them off) import { Buffer } from 'node:buffer' import { createServer, request } from 'node:http' import { connect } from 'node:net' import process from 'node:process' +import { handleGrid, newGridState } from './grid.js' import { handle } from './handlers.js' const args = process.argv.slice(2) @@ -29,6 +31,7 @@ if (!['real', 'bare'].includes(envelope) || !Number.isInteger(port)) { const [upstreamHost, upstreamPort = '80'] = upstream.split(':') const options = { envelope, alerts: false, down: [], rules: new Map() } +const grid = newGridState() const STATS = /^\/v[12]\/(?:sys|app_management|virt_management)\/stats(?:\/|$)/ function send(res, status, body) { @@ -51,8 +54,9 @@ const server = createServer((req, res) => { options.alerts = url.searchParams.get('on') === '1' return send(res, 200, { alerts: options.alerts }) } - if (!STATS.test(url.pathname)) { - return upstream ? proxy(req, res) : send(res, 404, { message: 'not_found' }) + const stats = STATS.test(url.pathname) + if (!stats && upstream) { + return proxy(req, res) } const chunks = [] req.on('data', chunk => chunks.push(chunk)) @@ -63,7 +67,8 @@ const server = createServer((req, res) => { } catch { return send(res, 400, { message: 'bad_rules: rules' }) } - const { status, body: answer } = handle({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), body }, options) + const request = { method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), body } + const { status, body: answer } = stats ? handle(request, options) : (handleGrid(request, grid) || { status: 404, body: { message: 'not_found' } }) send(res, status, answer) }) }) diff --git a/src/assets/lang/en_US.json b/src/assets/lang/en_US.json index 2e5cf83c..660e261b 100644 --- a/src/assets/lang/en_US.json +++ b/src/assets/lang/en_US.json @@ -2508,5 +2508,7 @@ "{seconds} s": "{seconds} s", "Exited with an error": "Exited with an error", "The settings are applied but could not be kept for good.": "The settings are applied but could not be kept for good.", - "System app": "System app" + "System app": "System app", + "Console of {name}": "Console of {name}", + "{name}, {state}: open in Virtual Machines": "{name}, {state}: open in Virtual Machines" } diff --git a/src/assets/lang/fr_FR.json b/src/assets/lang/fr_FR.json index 0939950d..d7db425b 100644 --- a/src/assets/lang/fr_FR.json +++ b/src/assets/lang/fr_FR.json @@ -2508,5 +2508,7 @@ "{seconds} s": "{seconds} s", "Exited with an error": "Arrêté avec une erreur", "The settings are applied but could not be kept for good.": "Les réglages sont appliqués, mais n'ont pas pu être conservés durablement.", - "System app": "Application système" + "System app": "Application système", + "Console of {name}": "Console de {name}", + "{name}, {state}: open in Virtual Machines": "{name}, {state} : ouvrir dans Machines virtuelles" } diff --git a/src/components/Apps/AppSection.spec.js b/src/components/Apps/AppSection.spec.js index 3b74ef38..ed9263d3 100644 --- a/src/components/Apps/AppSection.spec.js +++ b/src/components/Apps/AppSection.spec.js @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from 'vitest' -import { forgetVirtualMachines, hasVirtualMachines } from '@/components/VirtualMachines/entry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { forgetVirtualMachines, hasVirtualMachines, listVirtualMachines } from '@/components/VirtualMachines/entry' import AppSection from '@/components/Apps/AppSection.vue' import GitAppModal from '@/components/Apps/GitAppModal.vue' @@ -15,6 +15,7 @@ vi.mock('@/components/VirtualMachines/entry', async importOriginal => ({ ...await importOriginal(), hasVirtualMachines: vi.fn(), forgetVirtualMachines: vi.fn(), + listVirtualMachines: vi.fn(), vmApplication: () => ({ name: 'Virtual Machines' }), })) @@ -229,3 +230,286 @@ describe('app section built-in cards', () => { expect(vm.appList.find(app => app.name === 'Statistics')).toMatchObject({ app_type: 'system', status: 'running' }) }) }) + +// One tile per VM, right after the Virtual Machines card, by title. They are not +// apps: they never reach the saved order, whatever is dragged where. +describe('app section VM tiles', () => { + const aVm = (id, title = id, state = 'running') => ({ id, title, state }) + function section(names, vms = []) { + const vm = { + appList: names.map(name => ({ name })), + vms, + $api: { users: { setCustomStorage: vi.fn() } }, + $buefy: { toast: { open: vi.fn() } }, + } + Object.defineProperty(vm, 'gridList', { + get: () => AppSection.computed.gridList.get.call(vm), + set: list => AppSection.computed.gridList.set.call(vm, list), + }) + vm.pollVms = AppSection.methods.pollVms.bind(vm) + vm.saveSortData = AppSection.methods.saveSortData.bind(vm) + return vm + } + const shown = vm => vm.gridList.map(item => item.vmTile ? `tile:${item.vm.title}` : item.name) + const hide = (hidden) => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }) + } + + afterEach(() => { + vi.useRealTimers() + hide(false) + listVirtualMachines.mockReset() + }) + + it('(1, 4) shows a tile per VM right after the Virtual Machines card', () => { + const vm = section(['App Store', 'Virtual Machines', 'Files'], [aVm('a', 'Alpha'), aVm('b', 'Beta')]) + + expect(shown(vm)).toEqual(['App Store', 'Virtual Machines', 'tile:Alpha', 'tile:Beta', 'Files']) + }) + + it('(3) shows no tile and asks for no VM when the probe says there is no module', async () => { + const vm = section(['App Store', 'Files'], [aVm('a')]) + + await vm.pollVms() + + expect(listVirtualMachines).not.toHaveBeenCalled() + expect(shown(vm)).toEqual(['App Store', 'Files']) + }) + + it('(3) shows no tile and no toast when the list cannot be read', async () => { + listVirtualMachines.mockRejectedValue(new Error('502')) + const vm = section(['Virtual Machines'], [aVm('a')]) + + for (let i = 0; i < 3; i++) { + await vm.pollVms() + clearTimeout(vm.vmPollTimer) + } + + expect(shown(vm)).toEqual(['Virtual Machines']) + expect(vm.$buefy.toast.open).not.toHaveBeenCalled() + }) + + it('(4) keeps the order payload to the apps, whatever was dragged where', () => { + const vm = section(['App Store', 'Virtual Machines', 'Files', 'jellyfin'], [aVm('a'), aVm('b')]) + const [store, vms, a, b, files, jellyfin] = vm.gridList + + // jellyfin dropped between the tiles, and a tile dragged to the front + vm.gridList = [b, store, vms, a, jellyfin, files] + vm.saveSortData() + + expect(vm.$api.users.setCustomStorage).toHaveBeenCalledWith('app_order', { data: ['App Store', 'Virtual Machines', 'jellyfin', 'Files'] }) + // the tiles go back after their card + expect(shown(vm)).toEqual(['App Store', 'Virtual Machines', 'tile:a', 'tile:b', 'jellyfin', 'Files']) + }) + + it('(5) follows the states, and a VM that is deleted goes', async () => { + vi.useFakeTimers() + const vm = section(['Virtual Machines']) + listVirtualMachines.mockResolvedValueOnce([aVm('a'), aVm('b')]).mockResolvedValueOnce([aVm('a', 'a', 'shut-off')]) + + await vm.pollVms() + expect(shown(vm)).toEqual(['Virtual Machines', 'tile:a', 'tile:b']) + await vi.advanceTimersByTimeAsync(5000) + + expect(vm.vms).toEqual([aVm('a', 'a', 'shut-off')]) + AppSection.beforeUnmount.call({ ...vm, $EventBus: { $off: vi.fn() } }) + }) + + it('(3) reads the VMs as soon as the card joins the grid, and drops them with it', () => { + const pollVms = vi.fn() + + AppSection.watch.hasVmCard.call({ pollVms }) + + expect(pollVms).toHaveBeenCalledTimes(1) + expect(AppSection.computed.hasVmCard.call({ appList: [{ name: 'Virtual Machines' }] })).toBe(true) + expect(AppSection.computed.hasVmCard.call({ appList: [{ name: 'Files' }] })).toBe(false) + }) + + it('(5) asks once at a time', async () => { + let answer + listVirtualMachines.mockReturnValue(new Promise((resolve) => { + answer = resolve + })) + const vm = section(['Virtual Machines']) + + const first = vm.pollVms() + vm.pollVms() + answer([]) + await first + + expect(listVirtualMachines).toHaveBeenCalledTimes(1) + clearTimeout(vm.vmPollTimer) + }) + + it('(5) does not ask while the tab is hidden, and asks again once it is shown', async () => { + vi.useFakeTimers() + listVirtualMachines.mockResolvedValue([]) + const vm = section(['Virtual Machines']) + hide(true) + + await vm.pollVms() + await vi.advanceTimersByTimeAsync(30000) + expect(listVirtualMachines).not.toHaveBeenCalled() + + hide(false) + await AppSection.methods.onVisibility.call(vm) + expect(listVirtualMachines).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(5000) + expect(listVirtualMachines).toHaveBeenCalledTimes(2) + + // hidden again: the timer is dropped + hide(true) + AppSection.methods.onVisibility.call(vm) + await vi.advanceTimersByTimeAsync(30000) + expect(listVirtualMachines).toHaveBeenCalledTimes(2) + }) + + it('(5) stops for good when the grid goes, timer and listener with it', async () => { + vi.useFakeTimers() + listVirtualMachines.mockResolvedValue([]) + const vm = section(['Virtual Machines']) + vm.onVisibility = AppSection.methods.onVisibility.bind(vm) + const removed = vi.spyOn(document, 'removeEventListener') + await vm.pollVms() + + AppSection.beforeUnmount.call(Object.assign(vm, { $EventBus: { $off: vi.fn() } })) + await vi.advanceTimersByTimeAsync(30000) + + expect(listVirtualMachines).toHaveBeenCalledTimes(1) + expect(removed).toHaveBeenCalledWith('visibilitychange', vm.onVisibility) + expect(vi.getTimerCount()).toBe(0) + removed.mockRestore() + }) +}) + +// The tiles are not drop targets: an app is dropped among the apps only, so it +// never lands where the tiles are redrawn over it. +describe('app section VM tiles out of the drag', () => { + const tileItem = { name: 'vm-tile:a', vmTile: true, vm: { id: 'a' } } + const move = (vm, element, willInsertAfter = false) => + AppSection.methods.checkMove.call(vm, { relatedContext: { element }, willInsertAfter }) + + it('(1) starts no drag on a tile, and lets its click through', () => { + const options = AppSection.computed.dragOptions.call({}) + + expect(options.filter).toBe('.vm-tile-item') + expect(options.preventOnFilter).toBe(false) + }) + + it('(1) rejects a move over a tile', () => { + expect(move({ vms: [{ id: 'a' }] }, tileItem)).toBe(false) + expect(move({ vms: [{ id: 'a' }] }, tileItem, true)).toBe(false) + }) + + it('(1) rejects a drop right after the Virtual Machines card while tiles follow it', () => { + const card = { name: 'Virtual Machines' } + + expect(move({ vms: [{ id: 'a' }] }, card, true)).toBe(false) + expect(move({ vms: [{ id: 'a' }] }, card, false)).toBe(true) + expect(move({ vms: [] }, card, true)).toBe(true) + }) + + it('(1) lets an app move among the apps', () => { + expect(move({ vms: [{ id: 'a' }] }, { name: 'Files' })).toBe(true) + expect(move({ vms: [{ id: 'a' }] }, { name: 'Files' }, true)).toBe(true) + }) +}) + +describe('app section VM tiles riding out a failed poll', () => { + const aVm = (id, state = 'running') => ({ id, title: id, state }) + function section(vms = []) { + const vm = { appList: [{ name: 'Virtual Machines' }], vms } + vm.pollVms = AppSection.methods.pollVms.bind(vm) + return vm + } + afterEach(() => { + vi.useRealTimers() + listVirtualMachines.mockReset() + }) + const poll = async (vm) => { + await vm.pollVms() + clearTimeout(vm.vmPollTimer) + } + + it('(2) keeps the last list through one and two failed polls, and drops it on the third', async () => { + listVirtualMachines.mockRejectedValue(new Error('502')) + const vm = section([aVm('a')]) + + await poll(vm) + expect(vm.vms).toEqual([aVm('a')]) + await poll(vm) + expect(vm.vms).toEqual([aVm('a')]) + await poll(vm) + expect(vm.vms).toEqual([]) + }) + + it('(2) counts again from nothing once a poll works', async () => { + const vm = section([aVm('a')]) + listVirtualMachines.mockRejectedValueOnce(new Error('502')).mockRejectedValueOnce(new Error('502')) + .mockResolvedValueOnce([aVm('b')]) + .mockRejectedValueOnce(new Error('502')).mockRejectedValueOnce(new Error('502')) + + for (let i = 0; i < 5; i++) { + await poll(vm) + } + + expect(vm.vms).toEqual([aVm('b')]) + }) + + it('(2) drops the list at once when the module is not there', async () => { + listVirtualMachines.mockRejectedValue({ response: { status: 404 } }) + const vm = section([aVm('a')]) + + await poll(vm) + + expect(vm.vms).toEqual([]) + }) + + it('(2) drops the list at once when the card goes', async () => { + const vm = section([aVm('a')]) + vm.appList = [] + + await poll(vm) + + expect(vm.vms).toEqual([]) + }) + + it('(4) leaves the list as it is when nothing changed', async () => { + const vms = [aVm('a')] + listVirtualMachines.mockResolvedValue([aVm('a')]) + const vm = section(vms) + + await poll(vm) + + expect(vm.vms).toBe(vms) + }) + + it('(4) gives up on a read that hangs after 15 s, counts it as failed and polls again', async () => { + vi.useFakeTimers() + listVirtualMachines.mockReturnValueOnce(new Promise(() => {})).mockResolvedValue([aVm('b')]) + const vm = section([aVm('a')]) + + vm.pollVms() + await vi.advanceTimersByTimeAsync(15000) + expect(vm.vms).toEqual([aVm('a')]) + expect(vm.vmPolling).toBe(false) + expect(vm.vmFailures).toBe(1) + + await vi.advanceTimersByTimeAsync(5000) + expect(listVirtualMachines).toHaveBeenCalledTimes(2) + expect(vm.vms).toEqual([aVm('b')]) + clearTimeout(vm.vmPollTimer) + }) + + it('(5) declares its polling state when the grid is made', () => { + const vm = { getList: vi.fn(), isMobile: () => false, $EventBus: { $on: vi.fn() }, pollVms: vi.fn(), onVisibility: vi.fn() } + const added = vi.spyOn(document, 'addEventListener') + + AppSection.created.call(vm) + clearInterval(vm.ListRefreshTimer) + + expect(vm).toMatchObject({ vmPollStopped: false, vmPolling: false, vmPollTimer: null, vmFailures: 0 }) + expect(added).toHaveBeenCalledWith('visibilitychange', vm.onVisibility) + added.mockRestore() + }) +}) diff --git a/src/components/Apps/AppSection.vue b/src/components/Apps/AppSection.vue index 0b04db03..7002457c 100644 --- a/src/components/Apps/AppSection.vue +++ b/src/components/Apps/AppSection.vue @@ -40,17 +40,20 @@ their own grid while loading. --> + + + diff --git a/src/components/VirtualMachines/__fixtures__/page.js b/src/components/VirtualMachines/__fixtures__/page.js index f4027c3b..7637196b 100644 --- a/src/components/VirtualMachines/__fixtures__/page.js +++ b/src/components/VirtualMachines/__fixtures__/page.js @@ -80,6 +80,7 @@ export async function mountPage(options = {}) { const api = fakeApi() const socket = fakeSocket() const wrapper = mount(VmPage, { + props: options.props, attachTo: document.body, global: { plugins: [Buefy, i18n, [socketPlugin, socket]], mocks: { $api: api } }, }) diff --git a/src/components/VirtualMachines/entry.js b/src/components/VirtualMachines/entry.js index 8b5f555f..e70a1137 100644 --- a/src/components/VirtualMachines/entry.js +++ b/src/components/VirtualMachines/entry.js @@ -7,6 +7,7 @@ import { VMS_LAUNCH_ID, builtinOpensInNewTab } from '@/mixins/app/appLaunchPrefe // loaded when first opened: a box without the module never downloads the page const VmPage = defineAsyncComponent(() => import('./VmPage.vue')) +const VmConsoleModal = defineAsyncComponent(() => import('./VmConsoleModal.vue')) let probe = null @@ -54,10 +55,11 @@ export function forgetVirtualMachines() { } // Opens the page in a modal, or on a tab of its own when the App launching -// setting sends apps, or this one, to a new tab. -export function openVirtualMachines(buefy, state) { +// setting sends apps, or this one, to a new tab. With a VM's id, on that VM. +export function openVirtualMachines(buefy, state, vmId = '') { if (builtinOpensInNewTab(VMS_LAUNCH_ID, state)) { - window.open(`${window.location.pathname}#/vms`, '_blank') + const query = vmId ? `?vm=${encodeURIComponent(vmId)}` : '' + window.open(`${window.location.pathname}#/vms${query}`, '_blank') return null } @@ -68,5 +70,43 @@ export function openVirtualMachines(buefy, state) { scroll: 'keep', animation: 'zoom-in', customClass: 'vm-modal', + props: { selectId: vmId }, }) } + +// A VM's console, full screen, as the page opens it. Esc belongs to the VM: the +// console closes from its own button only. +export function openVmConsole(buefy, vm) { + return buefy.modal.open({ + component: VmConsoleModal, + fullScreen: true, + canCancel: [], + customClass: 'vm-console-modal', + props: { vm }, + }) +} + +// Every VM, by title, for the app grid's tiles. The client and the helpers are +// loaded on the first call: only a grid that has the module makes one. +export function listVirtualMachines() { + return Promise.all([import('./api'), import('./vms')]).then(([{ default: vmApi }, { applyOrder }]) => + vmApi.list().then((res) => { + const vms = (res.data?.data || []).filter(vm => typeof vm?.id === 'string') + return applyOrder(vms, []).sorted + })) +} + +// One VM as the service has it now, given up on after `ms`. +export function getVirtualMachine(id, ms) { + return withTimeout(import('./api').then(({ default: vmApi }) => vmApi.get(id)).then(res => res.data?.data), ms) +} + +// The promise, or a rejection once `ms` has gone by: a call that hangs must not +// hold up what waits on it for ever. The timer goes with the answer. +export function withTimeout(promise, ms) { + let timer + const late = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(`No answer in time (${ms} ms)`)), ms) + }) + return Promise.race([promise, late]).finally(() => clearTimeout(timer)) +} diff --git a/src/components/VirtualMachines/entry.spec.js b/src/components/VirtualMachines/entry.spec.js index 3fecbf2d..d12ed5ed 100644 --- a/src/components/VirtualMachines/entry.spec.js +++ b/src/components/VirtualMachines/entry.spec.js @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // The card shows only where the module answers through the gateway; a 404 // means it is not installed, anything else may pass and is asked again. @@ -40,3 +41,91 @@ describe('hasVirtualMachines', () => { expect(get).toHaveBeenCalledTimes(2) }) }) + +// The app grid's tiles: one per VM, by title, each opening its console or the +// page on that VM. +describe('the VM tiles of the app grid', () => { + beforeEach(() => { + get.mockReset() + }) + + it('lists the VMs by title, numbers as numbers, and drops what is not a VM', async () => { + get.mockResolvedValue({ data: { data: [ + { id: 'b', title: 'VM 10', state: 'running' }, + { id: 'a', title: 'VM 2', state: 'shut-off' }, + { id: 42, title: 'not a VM' }, + null, + ] } }) + const { listVirtualMachines } = await freshEntry() + + const vms = await listVirtualMachines() + + expect(get).toHaveBeenCalledWith('/v2/virt_management/vms') + expect(vms.map(vm => vm.id)).toEqual(['a', 'b']) + }) + + it('opens the page on the VM it is given', async () => { + const open = vi.fn(() => ({ close: vi.fn() })) + const { openVirtualMachines } = await freshEntry() + + openVirtualMachines({ modal: { open } }, {}, 'vm-2') + + expect(open.mock.calls[0][0].props).toEqual({ selectId: 'vm-2' }) + }) + + it('opens the page on its own tab on the VM, when the setting says so', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null) + const { openVirtualMachines } = await freshEntry() + + openVirtualMachines({ modal: { open: vi.fn() } }, { appLaunchInIframe: false, appLaunchExceptions: [] }, 'a/b c') + + expect(open).toHaveBeenCalledWith(expect.stringMatching(/#\/vms\?vm=a%2Fb%20c$/), '_blank') + open.mockRestore() + }) + + it('opens the console the way the page does, closed by its own button only', async () => { + const open = vi.fn() + const { openVmConsole } = await freshEntry() + const vm = { id: 'vm-1', title: 'Debian', state: 'running' } + + openVmConsole({ modal: { open } }, vm) + + expect(open.mock.calls[0][0]).toMatchObject({ fullScreen: true, canCancel: [], customClass: 'vm-console-modal', props: { vm } }) + }) +}) + +describe('reading one VM, and the time limits', () => { + beforeEach(() => { + get.mockReset() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('reads one VM by its id', async () => { + get.mockResolvedValue({ data: { data: { id: 'a/b', state: 'running' } } }) + const { getVirtualMachine } = await freshEntry() + + expect(await getVirtualMachine('a/b', 5000)).toEqual({ id: 'a/b', state: 'running' }) + expect(get).toHaveBeenCalledWith('/v2/virt_management/vms/a%2Fb') + }) + + it('gives up on a call that does not answer in time', async () => { + vi.useFakeTimers() + const { withTimeout } = await freshEntry() + + const caught = withTimeout(new Promise(() => {}), 5000).catch(e => e) + await vi.advanceTimersByTimeAsync(5000) + + expect((await caught).message).toMatch(/time/i) + expect(vi.getTimerCount()).toBe(0) + }) + + it('lets an answer in time through, and leaves no timer behind', async () => { + vi.useFakeTimers() + const { withTimeout } = await freshEntry() + + expect(await withTimeout(Promise.resolve(1), 5000)).toBe(1) + expect(vi.getTimerCount()).toBe(0) + }) +})