Add dashboard UI updates and settings modal

This commit is contained in:
Aaron
2025-12-10 18:51:31 -05:00
commit c85df728b7
54 changed files with 7151 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
const baseStatus = {
hostname: 'pikit',
ready: true,
uptime_seconds: 100,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 512 },
disk_mb: { total: 10240, free: 9000 },
cpu_temp_c: 40,
lan_ip: '10.0.0.10',
os_version: 'DietPi',
auto_updates_enabled: true,
auto_updates: { enabled: true },
services: [],
};
const defaultUpdatesConfig = {
enabled: true,
scope: 'all',
update_time: '04:00',
upgrade_time: '04:30',
cleanup: true,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
};
test('busy overlay appears while adding a service', async ({ page }) => {
let services = [];
await page.route('**/api/updates/config', async (route) => {
await route.fulfill({ json: defaultUpdatesConfig });
});
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...baseStatus, services } });
});
await page.goto('/');
// Trigger busy overlay via test hook
await page.evaluate(() => {
window.__pikitTest?.showBusy('Adding service', 'Opening firewall rules…');
setTimeout(() => window.__pikitTest?.hideBusy(), 300);
});
const busy = page.locator('#busyOverlay');
await expect(busy).toBeVisible();
await expect(busy).toBeHidden({ timeout: 2000 });
});

View File

@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
const services = [
{ name: 'Pi-hole', port: 8089, scheme: 'http', path: '/admin', url: 'http://pikit:8089/admin', online: true, firewall_open: true },
];
test('service cards show path in URL and preserve click target', async ({ page }) => {
await page.goto('/');
await page.evaluate(async (svcList) => {
const mod = await import('/assets/services.js');
const grid = document.getElementById('servicesGrid');
mod.renderServices(grid, svcList);
}, services);
await expect(page.getByText('Pi-hole')).toBeVisible();
await expect(page.getByText('http://pikit:8089/admin')).toBeVisible();
});

View File

@@ -0,0 +1,216 @@
import { test, expect } from '@playwright/test';
const baseStatus = {
hostname: 'pikit',
ready: true,
uptime_seconds: 100,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 512 },
disk_mb: { total: 10240, free: 9000 },
cpu_temp_c: 40,
lan_ip: '10.0.0.10',
os_version: 'DietPi',
auto_updates_enabled: true,
auto_updates: { enabled: true },
};
const defaultUpdatesConfig = {
enabled: true,
scope: 'all',
update_time: '04:00',
upgrade_time: '04:30',
cleanup: true,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
};
async function primeStatus(page, statusData) {
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: statusData });
});
}
async function stubUpdatesConfig(page, cfg = defaultUpdatesConfig) {
await page.route('**/api/updates/config', async (route) => {
if (route.request().method() === 'POST') {
const body = await route.request().postDataJSON();
await route.fulfill({ json: { ...cfg, ...body } });
return;
}
await route.fulfill({ json: cfg });
});
}
test('renders services from status payload', async ({ page }) => {
await stubUpdatesConfig(page);
const statusData = {
...baseStatus,
services: [
{ name: 'Pi-hole', port: 8089, scheme: 'http', path: '/admin', url: 'http://pikit:8089/admin', online: true },
{ name: 'Netdata', port: 19999, scheme: 'http', url: 'http://pikit:19999', online: false },
],
};
await primeStatus(page, statusData);
await page.goto('/');
await expect(page.getByText('Pi-hole')).toBeVisible();
await expect(page.getByText('http://pikit:8089/admin')).toBeVisible();
await expect(page.getByText('Netdata')).toBeVisible();
});
test('add service shows busy overlay and new card', async ({ page }) => {
await stubUpdatesConfig(page);
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
window.__pikitTest.forceServiceFormVisible = () => {
const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn'];
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.style.display = 'block';
el.style.opacity = '1';
el.style.visibility = 'visible';
el.style.maxHeight = '2000px';
}
});
};
});
const statusData = { ...baseStatus, services: [] };
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: statusData });
});
await page.route('**/api/services/add', async (route) => {
const body = await route.request().postDataJSON();
statusData.services.push({
name: body.name,
port: body.port,
scheme: body.scheme,
path: body.path,
url: `${body.scheme}://pikit:${body.port}${body.path || ''}`,
online: true,
});
await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Added' } });
});
await page.goto('/');
await page.click('#advBtn');
await page.evaluate(() => {
const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion');
acc?.classList.add('open');
window.__pikitTest?.forceServiceFormVisible?.();
});
await page.fill('#svcName', 'Grafana', { force: true });
await page.fill('#svcPort', '3000', { force: true });
await page.fill('#svcPath', '/dashboards', { force: true });
await page.evaluate(() => {
const sel = document.getElementById('svcScheme');
if (sel) {
sel.value = 'http';
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await page.click('#svcAddBtn', { force: true });
await expect(page.getByText('Grafana')).toBeVisible();
await expect(page.getByText('http://pikit:3000/dashboards')).toBeVisible();
});
test('path validation rejects absolute URLs', async ({ page }) => {
await stubUpdatesConfig(page);
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
window.__pikitTest.forceServiceFormVisible = () => {
const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn'];
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.style.display = 'block';
el.style.opacity = '1';
el.style.visibility = 'visible';
el.style.maxHeight = '2000px';
}
});
};
});
const statusData = { ...baseStatus, services: [] };
await primeStatus(page, statusData);
await page.goto('/');
await page.click('#advBtn');
await page.evaluate(() => {
const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion');
acc?.classList.add('open');
window.__pikitTest?.forceServiceFormVisible?.();
});
await page.fill('#svcName', 'BadPath', { force: true });
await page.fill('#svcPort', '8080', { force: true });
await page.fill('#svcPath', 'http://example.com', { force: true });
await page.click('#svcAddBtn', { force: true });
await expect(page.getByText('Path must be relative (e.g. /admin) or blank.')).toBeVisible();
});
test('edit service updates path and scheme', async ({ page }) => {
await stubUpdatesConfig(page);
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
});
const statusData = {
...baseStatus,
services: [
{ name: 'Uptime Kuma', port: 3001, scheme: 'http', path: '', url: 'http://pikit:3001', online: true },
],
};
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: statusData });
});
await page.route('**/api/services/update', async (route) => {
const body = await route.request().postDataJSON();
statusData.services = statusData.services.map((s) =>
s.port === body.port ? { ...s, scheme: body.scheme, path: body.path, url: `${body.scheme}://pikit:${s.port}${body.path || ''}` } : s
);
await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Service updated' } });
});
await page.goto('/');
await page.click('.menu-btn');
await page.fill('#menuPath', '/status');
await page.selectOption('#menuScheme', 'https');
const updateResp = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/services/update') && r.status() === 200),
page.click('#menuSaveBtn'),
]).then((res) => res[0]);
expect(updateResp.ok()).toBeTruthy();
const statusAfter = await page.evaluate(async () => {
const res = await fetch('/api/status');
return res.json();
});
const svc = statusAfter.services.find((s) => s.port === 3001);
expect(svc).toBeTruthy();
expect(svc.url).toContain('https://pikit:3001/status');
await page.reload();
await expect(page.locator('.service-url')).toContainText('https://pikit:3001/status', { timeout: 8000 });
});
test('remove service updates list', async ({ page }) => {
await stubUpdatesConfig(page);
let services = [
{ name: 'RemoveMe', port: 9000, scheme: 'http', url: 'http://pikit:9000', online: true },
];
const statusData = { ...baseStatus, services };
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...statusData, services } });
});
await page.route('**/api/services/remove', async (route) => {
const body = await route.request().postDataJSON();
services = services.filter((s) => s.port !== body.port);
await route.fulfill({ status: 200, json: { services, message: 'Removed' } });
});
await page.goto('/');
await page.click('.menu-btn');
await page.click('#menuRemoveBtn');
await expect(page.locator('.pill', { hasText: 'RemoveMe' })).toHaveCount(0, { timeout: 2000 });
});

View File

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test';
const baseStatus = {
hostname: 'pikit',
ready: true,
uptime_seconds: 100,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 512 },
disk_mb: { total: 10240, free: 9000 },
cpu_temp_c: 40,
lan_ip: '10.0.0.10',
os_version: 'DietPi',
auto_updates_enabled: true,
auto_updates: { enabled: true },
};
const defaultUpdatesConfig = {
enabled: true,
scope: 'all',
update_time: '04:00',
upgrade_time: '04:30',
cleanup: false,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
};
test('update settings form loads and saves config', async ({ page }) => {
let posted = null;
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
});
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...baseStatus, services: [] } });
});
await page.route('**/api/updates/config', async (route) => {
if (route.request().method() === 'POST') {
posted = await route.request().postDataJSON();
await route.fulfill({ json: { ...defaultUpdatesConfig, ...posted } });
return;
}
await route.fulfill({ json: defaultUpdatesConfig });
});
await page.goto('/');
await page.click('#advBtn');
await expect(page.locator('#acc-updates')).toBeVisible();
await page.selectOption('#updatesScope', 'security');
await page.fill('#updateTimeInput', '03:00');
await page.fill('#upgradeTimeInput', '03:30');
await page.click('#updatesCleanup');
await page.fill('#updatesBandwidth', '500');
await page.click('#updatesRebootToggle');
await page.fill('#updatesRebootTime', '03:45');
await page.click('#updatesRebootUsers');
const resp = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/updates/config') && r.request().method() === 'POST'),
page.click('#updatesSaveBtn'),
]);
expect(resp[0].ok()).toBeTruthy();
expect(posted).toMatchObject({
enable: true,
scope: 'security',
update_time: '03:00',
upgrade_time: '03:30',
cleanup: true,
bandwidth_limit_kbps: 500,
auto_reboot: true,
reboot_time: '03:45',
reboot_with_users: true,
});
await expect(page.getByText('Update settings saved.')).toBeVisible({ timeout: 2000 });
});
test('disabling updates disables controls and saves enable=false', async ({ page }) => {
let posted = null;
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
});
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...baseStatus, services: [] } });
});
await page.route('**/api/updates/config', async (route) => {
if (route.request().method() === 'POST') {
posted = await route.request().postDataJSON();
await route.fulfill({ json: { ...defaultUpdatesConfig, ...posted } });
return;
}
await route.fulfill({ json: defaultUpdatesConfig });
});
await page.goto('/');
await page.click('#advBtn');
await page.click('#updatesToggle + .slider', { force: true }); // disable via slider
await expect(page.locator('#updatesScope')).toBeDisabled();
await expect(page.locator('#updateTimeInput')).toBeDisabled();
await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/updates/config') && r.request().method() === 'POST'),
page.click('#updatesSaveBtn'),
]);
expect(posted).toMatchObject({ enable: false });
});