Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
143 changes: 143 additions & 0 deletions mock/stats-stub/grid.js
Original file line number Diff line number Diff line change
@@ -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: '<img src=x onerror=1>', 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
}
77 changes: 77 additions & 0 deletions mock/stats-stub/grid.spec.js
Original file line number Diff line number Diff line change
@@ -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('<img src=x onerror=1>')
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()
})
})
13 changes: 9 additions & 4 deletions mock/stats-stub/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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))
Expand All @@ -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)
})
})
Expand Down
4 changes: 3 additions & 1 deletion src/assets/lang/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
4 changes: 3 additions & 1 deletion src/assets/lang/fr_FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading
Loading