Add unit tests and new components for edge gateway workflows:

- Introduced unit tests for edge gateway workflow helpers, including workflow step resolution, incident action mapping, relay health row formatting, and workspace state merging.
- Added new components for advanced operations, configuration panel, context panel, fleet rail, and health summary.
- Enhanced gateway management UI with support for advanced actions, fallback operations, relay health visualization, and device binding features.
This commit is contained in:
Jeppe Bundgaard
2026-04-16 13:44:36 +02:00
parent 2d94322021
commit bef9eaeb72
34 changed files with 5296 additions and 2369 deletions
+27 -10
View File
@@ -98,7 +98,27 @@ jobs:
e2e-full:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
needs: build-and-unit
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
role: [customer, subuser, admin, superuser]
browser: [chromium, firefox, webkit]
device: [mobile, tablet, desktop]
include:
- browser: chromium
browser_label: Chromium
browser_install: chromium
- browser: firefox
browser_label: Firefox
browser_install: firefox
- browser: webkit
browser_label: WebKit
browser_install: webkit
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
PLAYWRIGHT_REPORTER_MODE: line-html
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -113,22 +133,19 @@ jobs:
run: npm ci --legacy-peer-deps
- name: Install Playwright browsers
run: npx playwright install --with-deps
run: npx playwright install --with-deps ${{ matrix.browser_install }}
- name: Run full Playwright suite
run: npm run test:e2e:ci
env:
PLAYWRIGHT_PARALLEL_WORKERS_CHROMIUM: 2
PLAYWRIGHT_PARALLEL_WORKERS_FIREFOX: 1
PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT: 2
- name: Run full Playwright slice
run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-full
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
path: |
output/playwright/ci-parallel-report
output/playwright/ci-parallel-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
if-no-files-found: ignore
retention-days: 14
+14 -1
View File
@@ -57,6 +57,7 @@ npm run test:e2e:batched:chromium -- --grep @smoke
```
Artifacts and summaries:
- `output/playwright/batched-chromium/last-run.json`
- `output/playwright/batched-chromium/failed-shards.json`
- `output/playwright/batched-chromium/report-index.html`
@@ -65,12 +66,22 @@ Artifacts and summaries:
## Playwright Full E2E
Run the permanent grouped full-suite entrypoint with a hard max of 5 total workers:
Run the permanent grouped full-suite entrypoint with a hard max of 5 total workers across:
- `chromium-(desktop|tablet|mobile)`
- `firefox-(desktop|tablet|mobile)`
- `webkit-(desktop|tablet|mobile)`
```sh
npm run test:e2e:ci
```
Run a single full-suite slice for one role and one Playwright project:
```sh
npm run test:e2e:full:slice -- --role=admin --project=webkit-tablet
```
Default worker allocation:
```sh
@@ -91,10 +102,12 @@ PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=2
The runner fails fast if the combined worker count exceeds 5.
Artifacts and summaries:
- `output/playwright/ci-parallel-report/index.html`
- `output/playwright/ci-parallel-chromium/report/index.html`
- `output/playwright/ci-parallel-firefox/report/index.html`
- `output/playwright/ci-parallel-webkit/report/index.html`
- `output/playwright/test-lists/<role>-<project>.txt`
## Bubblewrap (TWA) Build and Install
+1
View File
@@ -19,6 +19,7 @@
"test:e2e": "playwright test",
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
"test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs",
"test:e2e:full:slice": "node scripts/run-playwright-full-slice.mjs",
"test:e2e:ci:serial": "playwright test --reporter=line,html",
"test:e2e:batched:chromium": "node scripts/run-playwright-batched-chromium.mjs",
"test:e2e:batched:chromium:rerun-failed": "node scripts/run-playwright-batched-chromium.mjs --rerun-failed",
+30 -40
View File
@@ -14,14 +14,25 @@ const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? co
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
const reporter =
reporterMode === "line-html"
? [
["line"],
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
]
: [
["list"],
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
];
? [["line"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]]
: [["list"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]];
function buildProject(name: string, browserName: "chromium" | "firefox" | "webkit", deviceName: keyof typeof devices) {
const { defaultBrowserType, ...device } = devices[deviceName];
const use = {
browserName,
...device,
};
if (browserName === "firefox") {
delete use.isMobile;
}
return {
name,
use,
};
}
export default defineConfig({
testDir: "./tests/e2e",
@@ -43,38 +54,17 @@ export default defineConfig({
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure"
video: "retain-on-failure",
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"]
}
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"]
}
},
{
name: "firefox-desktop",
use: {
...devices["Desktop Firefox"]
}
},
{
name: "webkit-desktop",
use: {
...devices["Desktop Safari"]
}
},
{
name: "webkit-mobile",
use: {
...devices["iPhone 12"]
}
}
]
buildProject("chromium-desktop", "chromium", "Desktop Chrome"),
buildProject("chromium-tablet", "chromium", "iPad Mini"),
buildProject("chromium-mobile", "chromium", "Pixel 5"),
buildProject("firefox-desktop", "firefox", "Desktop Firefox"),
buildProject("firefox-tablet", "firefox", "iPad Mini"),
buildProject("firefox-mobile", "firefox", "Pixel 5"),
buildProject("webkit-desktop", "webkit", "Desktop Safari"),
buildProject("webkit-tablet", "webkit", "iPad Mini"),
buildProject("webkit-mobile", "webkit", "iPhone 12"),
],
});
+70 -14
View File
@@ -10,6 +10,8 @@ const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `po
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-");
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
const posViewsDirectory = path.resolve(process.cwd(), "src/views/dashboards/departmentDashboard/modules/Pos");
const shopComponentsDirectory = path.resolve(process.cwd(), "src/components/shop");
async function getListeningProcessOnWindows(port) {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
@@ -102,6 +104,63 @@ async function waitForServerReady(url, timeoutMs = 120_000) {
throw new Error(`Timed out waiting for the local Playwright dev server at ${url}.`);
}
async function buildWarmupTargets() {
const [posViewFiles, shopComponentFiles] = await Promise.all([
fs.readdir(posViewsDirectory),
fs.readdir(shopComponentsDirectory),
]);
return [
{ pathname: "/admin/12/modules/pos?step=1", expectedContentType: "text/html" },
{ pathname: "/admin/12/modules/pos/orders/54518", expectedContentType: "text/html" },
{ pathname: "/@vite/client", expectedContentType: "javascript" },
{ pathname: "/src/main.js", expectedContentType: "javascript" },
{ pathname: "/src/router.js", expectedContentType: "javascript" },
...posViewFiles
.filter((fileName) => fileName.endsWith(".vue"))
.map((fileName) => ({
pathname: `/src/views/dashboards/departmentDashboard/modules/Pos/${fileName}`,
expectedContentType: "javascript",
})),
...shopComponentFiles
.filter((fileName) => fileName.endsWith(".vue"))
.map((fileName) => ({
pathname: `/src/components/shop/${fileName}`,
expectedContentType: "javascript",
})),
];
}
async function warmUpAsset(url, expectedContentType, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual" });
const contentType = response.headers.get("content-type") || "";
if (response.ok && contentType.toLowerCase().includes(expectedContentType)) {
await response.arrayBuffer();
return;
}
} catch {
// keep polling until the Vite transform is ready
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out warming Playwright dev asset ${url}.`);
}
async function warmUpDevServer(url) {
const warmupTargets = await buildWarmupTargets();
for (const target of warmupTargets) {
await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType);
}
}
export default async function globalSetup() {
if (process.env.PLAYWRIGHT_BASE_URL) {
return;
@@ -121,20 +180,16 @@ export default async function globalSetup() {
const serverProcess =
process.platform === "win32"
? spawn(
"cmd.exe",
["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`],
{
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
windowsHide: true,
}
)
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
windowsHide: true,
})
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
cwd: process.cwd(),
detached: true,
@@ -151,6 +206,7 @@ export default async function globalSetup() {
try {
await waitForServerReady(baseURL);
await warmUpDevServer(baseURL);
} catch (error) {
await killProcessTree(serverProcess.pid);
throw error;
+13 -10
View File
@@ -17,17 +17,17 @@ let isShuttingDown = false;
const groups = [
{
name: "chromium",
projects: ["chromium-desktop", "chromium-mobile"],
projects: ["chromium-desktop", "chromium-tablet", "chromium-mobile"],
defaultWorkers: 2,
},
{
name: "firefox",
projects: ["firefox-desktop"],
projects: ["firefox-desktop", "firefox-tablet", "firefox-mobile"],
defaultWorkers: 1,
},
{
name: "webkit",
projects: ["webkit-desktop", "webkit-mobile"],
projects: ["webkit-desktop", "webkit-tablet", "webkit-mobile"],
defaultWorkers: 2,
},
];
@@ -130,11 +130,7 @@ function prefixStream(stream, prefix) {
function spawnGroup(group, index) {
const devPort = basePort + index;
const artifactNamespace = getArtifactNamespace(group);
const args = [
"test",
...forwardedArgs,
...group.projects.flatMap((project) => ["--project", project]),
];
const args = ["test", ...forwardedArgs, ...group.projects.flatMap((project) => ["--project", project])];
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
cwd: workingDirectory,
@@ -257,7 +253,11 @@ async function main() {
const totalWorkers = validateWorkerCap(configuredGroups);
console.log(
`Starting Playwright CI in parallel with ${configuredGroups.length} processes and ${totalWorkers} total worker(s): ${configuredGroups.map((group) => `${group.name}=${group.workers}`).join(", ")}.`
`Starting Playwright CI in parallel with ${
configuredGroups.length
} processes and ${totalWorkers} total worker(s): ${configuredGroups
.map((group) => `${group.name}=${group.workers}`)
.join(", ")}.`
);
await Promise.all(configuredGroups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
@@ -274,7 +274,10 @@ async function main() {
}
console.log(
`Parallel Playwright CI passed. Combined report index: ${path.relative(workingDirectory, path.join(reportIndexDirectory, "index.html"))}`
`Parallel Playwright CI passed. Combined report index: ${path.relative(
workingDirectory,
path.join(reportIndexDirectory, "index.html")
)}`
);
}
+327
View File
@@ -0,0 +1,327 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const execFileAsync = promisify(execFile);
const roles = ["customer", "subuser", "admin", "superuser"];
const listEntryPattern = /^\s+\[[^\]]+\]\s+\s+(.+?):(\d+):(\d+)\s+\s+(.+)\s*$/u;
const ownedFilesByRole = {
customer: [
"auth.smoke.spec.js",
"booking-selfserve.smoke.spec.js",
"connectivityIssue.spec.ts",
"example.spec.ts",
"i18n.smoke.spec.ts",
"i18n.views.spec.ts",
"navigation.smoke.spec.js",
"self-serve-wash.spec.js",
"user-orders.spec.ts",
"userBookings.spec.ts",
"userBookWash.spec.ts",
"userHome.spec.ts",
"userInvoices.spec.ts",
"userMyWashStart.spec.ts",
"userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts",
"userVehicles.spec.ts",
],
subuser: [
"subuserCompleteRegistration.spec.ts",
"subuserProfileContact.spec.ts",
"subuserProfileGrant.spec.ts",
"subuserProfileInformation.spec.ts",
"subuserProfileSecurity.spec.ts",
"subuserProfileUsername.spec.ts",
],
admin: [
"admin-bookings-mobile.spec.ts",
"admin-daily-report.spec.ts",
"admin-department-visibility.spec.ts",
"admin-overview-mobile.spec.ts",
"admin-overview-night-washes.spec.ts",
"admin-pos-orders.spec.ts",
"adminModuleGoals.spec.ts",
"adminModulePosMobileOrderFlow.spec.ts",
"change-invoice-collection.spec.ts",
"economic-queue-workflow.spec.js",
"pos-customer-rules.spec.js",
"pos-desktop-card-payments.spec.js",
"pos-flow.spec.js",
"pos-mobile-card-payments.spec.js",
"pos-mobile-order-flow.spec.js",
"pos.visual.spec.js",
],
superuser: [
"edge-gateways.routes.spec.js",
"edge-gateways.smoke.spec.js",
"edge-gateways.visual.spec.js",
"invoice-distribution.smoke.spec.js",
"invoice-transfer-queue-history.spec.js",
"invoicing-period.smoke.spec.js",
"superuser-customer-complaints.spec.ts",
"superuser-department-gates.spec.ts",
"superuser-system-status.smoke.spec.js",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
],
};
const titleRules = [
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
{ role: "customer", file: "twoFactorAuth.spec.ts", patterns: [/\[AUTH\]\[2FA\]\[User\]/u] },
{ role: "subuser", file: "twoFactorAuth.spec.ts", patterns: [/\[AUTH\]\[2FA\]\[Subuser\]/u] },
{
role: "customer",
file: "passkeyAuth.spec.ts",
patterns: [
/\[AUTH\]\[Passkey\]\[User\]/u,
/\[AUTH\]\[Passkey\]\[Browser Support\]/u,
/\[AUTH\]\[Passkey\]\[Button State\]/u,
],
},
{ role: "subuser", file: "passkeyAuth.spec.ts", patterns: [/\[AUTH\]\[Passkey\]\[Subuser\]/u] },
{
role: "customer",
file: "subuser-management.spec.ts",
patterns: [/^customer user can invite and manage grant access/i],
},
{ role: "customer", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[User\]\[Visibility\]/u] },
{
role: "subuser",
file: "subuser-management.spec.ts",
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
},
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
];
const ownedFileToRole = new Map();
for (const role of roles) {
for (const file of ownedFilesByRole[role]) {
if (ownedFileToRole.has(file)) {
throw new Error(`Duplicate role ownership for ${file}.`);
}
ownedFileToRole.set(file, role);
}
}
function parseCliArgs(argv) {
const separatorIndex = argv.indexOf("--");
const optionArgs = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
const forwardedArgs = separatorIndex === -1 ? [] : argv.slice(separatorIndex + 1);
const options = {
role: "",
project: "",
listOnly: false,
};
for (let index = 0; index < optionArgs.length; index += 1) {
const arg = optionArgs[index];
if (arg === "--role") {
options.role = optionArgs[index + 1] || "";
index += 1;
continue;
}
if (arg.startsWith("--role=")) {
options.role = arg.slice("--role=".length);
continue;
}
if (arg === "--project") {
options.project = optionArgs[index + 1] || "";
index += 1;
continue;
}
if (arg.startsWith("--project=")) {
options.project = arg.slice("--project=".length);
continue;
}
if (arg === "--list-only") {
options.listOnly = true;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return { options, forwardedArgs };
}
function validateOptions(options, forwardedArgs) {
if (!roles.includes(options.role)) {
throw new Error(`--role must be one of: ${roles.join(", ")}`);
}
if (!options.project) {
throw new Error("--project is required.");
}
for (const arg of forwardedArgs) {
if (arg === "--list" || arg === "--test-list" || arg === "--project") {
throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`);
}
if (arg.startsWith("--list=") || arg.startsWith("--test-list=") || arg.startsWith("--project=")) {
throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`);
}
}
}
function toBaseName(filePath) {
return filePath.split(/[\\/]/u).pop() || filePath;
}
function parseListedTests(listOutput) {
return listOutput
.split(/\r?\n/u)
.map((line) => {
const match = line.match(listEntryPattern);
if (!match) {
return null;
}
const [, relativeFile, lineNumber, columnNumber, title] = match;
return {
relativeFile,
fileName: toBaseName(relativeFile),
lineNumber: Number(lineNumber),
columnNumber: Number(columnNumber),
title,
listLine: line.trim(),
};
})
.filter(Boolean);
}
function classifyTest(testEntry) {
const matches = new Set();
const directOwner = ownedFileToRole.get(testEntry.fileName);
if (directOwner) {
matches.add(directOwner);
}
for (const rule of titleRules) {
if (rule.file !== testEntry.fileName) {
continue;
}
if (rule.patterns.some((pattern) => pattern.test(testEntry.title))) {
matches.add(rule.role);
}
}
if (matches.size !== 1) {
const location = `${testEntry.relativeFile}:${testEntry.lineNumber}:${testEntry.columnNumber}`;
if (matches.size === 0) {
throw new Error(`Unclassified test: ${location} ${testEntry.title}`);
}
throw new Error(`Ambiguous role ownership for test: ${location} ${testEntry.title}`);
}
return [...matches][0];
}
async function listProjectTests(project, forwardedArgs) {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[playwrightCliPath, "test", "--list", `--project=${project}`, ...forwardedArgs],
{
cwd: workingDirectory,
maxBuffer: 64 * 1024 * 1024,
}
);
if (stderr.trim()) {
process.stderr.write(stderr);
}
return stdout;
}
async function writeTestList(role, project, matchingTests) {
const outputDirectory = path.join(workingDirectory, "output", "playwright", "test-lists");
await fs.mkdir(outputDirectory, { recursive: true });
const testListPath = path.join(outputDirectory, `${role}-${project}.txt`);
await fs.writeFile(testListPath, `${matchingTests.map((testEntry) => testEntry.listLine).join("\n")}\n`, "utf8");
return testListPath;
}
async function runPlaywright(project, testListPath, forwardedArgs) {
const args = ["test", `--project=${project}`, `--test-list=${testListPath}`, ...forwardedArgs];
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
cwd: workingDirectory,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "inherit",
windowsHide: true,
});
child.on("error", reject);
child.on("close", (code, signal) => {
if (signal) {
reject(new Error(`Playwright exited due to signal ${signal}.`));
return;
}
if ((code ?? 1) !== 0) {
reject(new Error(`Playwright exited with code ${code ?? 1}.`));
return;
}
resolve();
});
});
}
async function main() {
const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
validateOptions(options, forwardedArgs);
const listOutput = await listProjectTests(options.project, forwardedArgs);
const listedTests = parseListedTests(listOutput);
const classifiedTests = listedTests.map((testEntry) => ({
...testEntry,
role: classifyTest(testEntry),
}));
const matchingTests = classifiedTests.filter((testEntry) => testEntry.role === options.role);
console.log(
`Resolved ${matchingTests.length} ${options.role} test(s) out of ${classifiedTests.length} listed test(s) for ${options.project}.`
);
if (matchingTests.length === 0) {
console.log(`No ${options.role} tests matched for ${options.project}. Nothing to run.`);
return;
}
if (options.listOnly) {
for (const testEntry of matchingTests) {
console.log(testEntry.listLine);
}
return;
}
const testListPath = await writeTestList(options.role, options.project, matchingTests);
console.log(`Using generated test list: ${path.relative(workingDirectory, testListPath)}`);
await runPlaywright(options.project, testListPath, forwardedArgs);
}
await main();
@@ -0,0 +1,307 @@
<script setup>
import EdgeGatewayTerminal from "./EdgeGatewayTerminal.vue";
defineProps({
gateway: {
type: Object,
default: null,
},
advancedOperationsOpen: {
type: Boolean,
default: false,
},
rawRelayHealthOpen: {
type: Boolean,
default: false,
},
rotationConfirmationOpen: {
type: Boolean,
default: false,
},
rotatedAgentToken: {
type: String,
default: "",
},
rotationLoading: {
type: Boolean,
default: false,
},
cutoverLoading: {
type: Boolean,
default: false,
},
uninstallConfirmationOpen: {
type: Boolean,
default: false,
},
uninstallLoading: {
type: Boolean,
default: false,
},
deleteConfirmationOpen: {
type: Boolean,
default: false,
},
deleteLoading: {
type: Boolean,
default: false,
},
transportMode: {
type: String,
default: "gateway",
},
});
const emit = defineEmits([
"toggle-advanced",
"toggle-relay-health",
"update:transportMode",
"rotate-credentials",
"copy-rotated-credential",
"apply-cutover-mode",
"session-approved",
"open-rotation-confirmation",
"close-rotation-confirmation",
"open-uninstall-confirmation",
"close-uninstall-confirmation",
"queue-uninstall",
"open-delete-confirmation",
"close-delete-confirmation",
"delete-gateway",
]);
</script>
<template>
<article class="edge-card edge-card--advanced" data-testid="gateway-advanced-operations">
<header class="edge-card__header edge-card__header--row">
<div>
<p class="edge-eyebrow">Avancerede handlinger</p>
<h3>Break-glass og fallback-værktøjer</h3>
</div>
<button type="button" class="button is-light" @click="emit('toggle-advanced')">
{{ advancedOperationsOpen ? "Skjul avanceret" : "Vis avanceret" }}
</button>
</header>
<p class="edge-helper-text">
Sekundære eller risikofyldte handlinger er samlet her, den normale arbejdsflade kan forblive fokuseret.
</p>
<div v-if="advancedOperationsOpen" class="edge-advanced-stack">
<section class="edge-card edge-card--subtle">
<header class="edge-card__header edge-card__header--row">
<div>
<p class="edge-eyebrow">Credentials og cutover</p>
<h4>Nødindgreb</h4>
</div>
<div class="edge-inline-actions">
<button
type="button"
class="button is-warning"
:class="{ 'is-loading': rotationLoading }"
data-testid="gateway-rotate-credentials"
@click="emit('open-rotation-confirmation')"
>
Rotér credentials
</button>
<button
type="button"
class="button is-light"
:class="{ 'is-loading': cutoverLoading }"
data-testid="gateway-cutover-apply"
@click="emit('apply-cutover-mode')"
>
Anvend cutover
</button>
</div>
</header>
<div class="edge-form-grid edge-form-grid--cutover">
<label class="field">
<span class="label">Transporttilstand</span>
<div class="select is-fullwidth">
<select :value="transportMode" aria-label="Transporttilstand" @change="emit('update:transportMode', $event.target.value)">
<option value="gateway">Lokal gateway</option>
<option value="cloud">Cloud fallback</option>
</select>
</div>
</label>
</div>
<div v-if="rotationConfirmationOpen" class="edge-rotation-panel" data-testid="gateway-rotation-confirmation">
<p class="edge-helper-text">
Rotation udsteder en ny agent-token og kræver, at gateway-agenten opdateres bagefter.
</p>
<div class="edge-inline-actions">
<button
type="button"
class="button is-warning"
:class="{ 'is-loading': rotationLoading }"
data-testid="gateway-rotation-confirm"
@click="emit('rotate-credentials')"
>
Bekræft rotation
</button>
<button
type="button"
class="button is-light"
data-testid="gateway-rotation-cancel"
@click="emit('close-rotation-confirmation')"
>
Annuller
</button>
</div>
</div>
<div
v-if="rotatedAgentToken"
class="edge-rotation-panel edge-rotation-panel--success"
data-testid="gateway-rotation-result"
>
<label class="field">
<span class="label">Ny agent-token</span>
<textarea :value="rotatedAgentToken" class="textarea edge-command" rows="3" readonly data-testid="gateway-rotation-token" />
</label>
<button
type="button"
class="button is-light"
data-testid="gateway-rotation-copy"
@click="emit('copy-rotated-credential')"
>
Kopiér ny token
</button>
</div>
</section>
<section class="edge-card edge-card--subtle">
<header class="edge-card__header edge-card__header--row">
<div>
<p class="edge-eyebrow">Fjern Pi</p>
<h4>Afinstallér eller slet gateway</h4>
</div>
<div class="edge-inline-actions">
<button
type="button"
class="button is-warning"
:disabled="!gateway || gateway.status === 'OFFLINE'"
:class="{ 'is-loading': uninstallLoading }"
data-testid="gateway-uninstall"
@click="emit('open-uninstall-confirmation')"
>
Afinstallér Pi
</button>
<button
type="button"
class="button is-danger is-light"
:class="{ 'is-loading': deleteLoading }"
data-testid="gateway-delete"
@click="emit('open-delete-confirmation')"
>
Slet gateway
</button>
</div>
</header>
<p class="edge-helper-text">
Afinstallering kræver, at gatewayen stadig svarer, agenten kan stoppe sig selv Raspberry Pi'en.
Sletning fjerner kun registreringen i TruckWash.
</p>
<div v-if="uninstallConfirmationOpen" class="edge-rotation-panel" data-testid="gateway-uninstall-confirmation">
<p class="edge-helper-text">
Pi'en stopper edge-agenten og rydder sin lokale tilknytning. Brug denne, når du stadig kan gatewayen.
</p>
<div class="edge-inline-actions">
<button
type="button"
class="button is-warning"
:class="{ 'is-loading': uninstallLoading }"
data-testid="gateway-uninstall-confirm"
@click="emit('queue-uninstall')"
>
Bekræft afinstallering
</button>
<button
type="button"
class="button is-light"
data-testid="gateway-uninstall-cancel"
@click="emit('close-uninstall-confirmation')"
>
Annuller
</button>
</div>
</div>
<div v-if="deleteConfirmationOpen" class="edge-rotation-panel" data-testid="gateway-delete-confirmation">
<p class="edge-helper-text">
Sletning skjuler gatewayen fra flåden og afbryder fremtidige heartbeats. Brug denne, når Pi'en allerede er væk
eller ikke skal styres herfra længere.
</p>
<div class="edge-inline-actions">
<button
type="button"
class="button is-danger"
:class="{ 'is-loading': deleteLoading }"
data-testid="gateway-delete-confirm"
@click="emit('delete-gateway')"
>
Bekræft sletning
</button>
<button
type="button"
class="button is-light"
data-testid="gateway-delete-cancel"
@click="emit('close-delete-confirmation')"
>
Annuller
</button>
</div>
</div>
</section>
<section class="edge-card edge-card--subtle">
<header class="edge-card__header edge-card__header--row">
<div>
<p class="edge-eyebrow">Fallback-detaljer</p>
<h4>Rå relay-health</h4>
</div>
<button type="button" class="button is-light" @click="emit('toggle-relay-health')">
{{ rawRelayHealthOpen ? "Skjul tabel" : "Vis tabel" }}
</button>
</header>
<div v-if="rawRelayHealthOpen" class="edge-table-shell">
<table class="table is-fullwidth is-hoverable edge-table" data-testid="gateway-relay-health-table">
<thead>
<tr>
<th>Binding</th>
<th>Kørsel</th>
<th>Fallback</th>
<th>Enhed</th>
<th>Årsag</th>
</tr>
</thead>
<tbody>
<tr v-for="relay in gateway.relay_health" :key="`health-${relay.binding_id || relay.relay_id}`">
<td>{{ relay.relay_id }}</td>
<td>{{ relay.executionPathLabel }}</td>
<td>{{ relay.fallbackModeLabel }}</td>
<td>{{ relay.freshnessLabel }}</td>
<td>{{ relay.reasonLabel }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section data-testid="gateway-step-shell">
<EdgeGatewayTerminal
:key="gateway.id || 'no-gateway'"
:gateway-id="gateway.id"
:latest-session="gateway.recent_shell_sessions?.[0] ?? null"
@session-approved="emit('session-approved', $event)"
/>
</section>
</div>
</article>
</template>
@@ -0,0 +1,203 @@
<script setup>
defineProps({
gateway: {
type: Object,
default: null,
},
editableBindings: {
type: Array,
default: () => [],
},
bindingHelperText: {
type: String,
default: "",
},
emptyBindingStateDescription: {
type: String,
default: "",
},
discoveryLoading: {
type: Boolean,
default: false,
},
bindingSaveLoading: {
type: Boolean,
default: false,
},
inventoryDeviceOptions: {
type: Array,
default: () => [],
},
getBindingDeviceOptions: {
type: Function,
required: true,
},
getBindingChannelOptions: {
type: Function,
required: true,
},
});
const emit = defineEmits([
"queue-discovery",
"add-binding",
"remove-binding",
"update-binding-field",
"sync-binding-device",
"save-bindings",
]);
</script>
<template>
<section v-if="gateway" class="edge-step-stack" data-testid="gateway-step-configure-panel">
<article class="edge-card">
<header class="edge-card__header edge-card__header--row">
<div>
<p class="edge-eyebrow">Discovery</p>
<h3>Lokale Shelly-enheder</h3>
</div>
<button
type="button"
class="button is-dark edge-button-primary"
:class="{ 'is-loading': discoveryLoading }"
data-testid="gateway-discovery-action"
@click="emit('queue-discovery')"
>
Kør discovery
</button>
</header>
<p class="edge-helper-text">
Discovery-status: <strong>{{ gateway.discoveryStatusLabel }}</strong> ·
{{ gateway.lastSuccessfulDiscoveryRelative }}
</p>
<div v-if="!gateway.inventory.length" class="edge-empty-state edge-empty-state--inline">
<h3>Ingen Shelly-enheder fundet endnu</h3>
<p>Kør discovery for at scanne det lokale netværk og hente en opdateret enhedsliste.</p>
</div>
<div v-else class="edge-device-grid">
<article v-for="device in gateway.inventory" :key="device.id" class="edge-device-card">
<div class="edge-device-card__topline">
<strong>{{ device.model }}</strong>
<span class="edge-pill" :data-tone="device.online === false ? 'danger' : 'success'">
{{ device.online === false ? "Offline" : "Klar" }}
</span>
</div>
<p>{{ device.device_id }}</p>
<small>{{ device.local_ip }} · {{ device.channel_count }} kanaler</small>
</article>
</div>
</article>
<article class="edge-card">
<header class="edge-card__header edge-card__header--row">
<div>
<p class="edge-eyebrow">Bindinger</p>
<h3>Konfigurer lokal relay-styring</h3>
</div>
<div class="edge-inline-actions">
<button type="button" class="button is-light" @click="emit('add-binding')">Tilføj binding</button>
<button
type="button"
class="button is-dark edge-button-primary"
:class="{ 'is-loading': bindingSaveLoading }"
data-testid="gateway-bindings-save"
@click="emit('save-bindings')"
>
Gem bindinger
</button>
</div>
</header>
<p class="edge-helper-text">{{ bindingHelperText }}</p>
<div v-if="!editableBindings.length" class="edge-empty-state edge-empty-state--inline">
<h3>Ingen bindinger endnu</h3>
<p>{{ emptyBindingStateDescription }}</p>
</div>
<div v-else class="edge-binding-stack">
<article v-for="(binding, index) in editableBindings" :key="`${index}-${binding.relay_id}`" class="edge-binding-card">
<div class="edge-binding-card__header">
<div>
<p class="edge-eyebrow">Binding {{ index + 1 }}</p>
<h4>{{ binding.relay_id || "Nyt relay" }}</h4>
</div>
<button type="button" class="button is-white is-small" @click="emit('remove-binding', index)">Fjern</button>
</div>
<div class="edge-form-grid edge-form-grid--binding">
<label class="field">
<span class="label">Relæ-id</span>
<input
:value="binding.relay_id"
class="input"
type="text"
placeholder="F.eks. M-7"
@input="emit('update-binding-field', index, 'relay_id', $event.target.value)"
/>
</label>
<label class="field">
<span class="label">Shelly-device</span>
<div class="select is-fullwidth">
<select
:value="binding.device_id"
@change="
emit('update-binding-field', index, 'device_id', $event.target.value);
emit('sync-binding-device', { ...binding, device_id: $event.target.value }, index);
"
>
<option value="">Vælg device</option>
<option
v-for="device in getBindingDeviceOptions(binding)"
:key="device.value"
:value="device.value"
>
{{ device.label }}
</option>
</select>
</div>
</label>
<label class="field">
<span class="label">IP</span>
<input :value="binding.local_ip" class="input" type="text" readonly />
</label>
<label class="field">
<span class="label">Kanal</span>
<div class="select is-fullwidth">
<select
:value="binding.channel"
@change="emit('update-binding-field', index, 'channel', Number($event.target.value))"
>
<option v-for="channel in getBindingChannelOptions(binding)" :key="channel" :value="channel">
{{ channel }}
</option>
</select>
</div>
</label>
<label class="field">
<span class="label">Fallback</span>
<div class="select is-fullwidth">
<select
:value="binding.fallback_mode"
aria-label="Fallback mode"
@change="emit('update-binding-field', index, 'fallback_mode', $event.target.value)"
>
<option value="PREFER_LOCAL">Foretræk lokal</option>
<option value="LOCAL_ONLY">Kun lokal</option>
<option value="CLOUD_ONLY">Kun cloud</option>
</select>
</div>
</label>
</div>
</article>
</div>
</article>
</section>
</template>
@@ -0,0 +1,107 @@
<script setup>
defineProps({
gateway: {
type: Object,
default: null,
},
selectedGatewayShellSummary: {
type: String,
default: "",
},
advancedOperationsOpen: {
type: Boolean,
default: false,
},
departmentScoped: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["run-primary-action", "open-advanced", "open-gateway-page"]);
</script>
<template>
<aside v-if="gateway" class="edge-context-panel" data-testid="gateway-context-panel">
<section class="edge-context-panel__section edge-context-panel__section--hero">
<p class="edge-eyebrow">Aktuel gateway</p>
<h3>{{ gateway.displayLabel }}</h3>
<p class="edge-context-panel__summary">{{ gateway.incidentSummary }}</p>
<div class="edge-context-panel__badges">
<span class="edge-pill edge-pill--lg" :data-tone="gateway.statusTone">{{ gateway.statusLabel }}</span>
<span class="edge-pill edge-pill--lg" data-tone="neutral">{{ gateway.discoveryStatusLabel }}</span>
<span class="edge-pill edge-pill--lg" :data-tone="gateway.brokerStatusTone">{{ gateway.brokerStatusLabel }}</span>
</div>
<button
type="button"
class="button is-dark edge-button-primary edge-context-panel__cta"
data-testid="gateway-step-primary-assess"
@click="emit('run-primary-action')"
>
{{ gateway.incidentPrimaryAction.label }}
</button>
<p class="edge-context-panel__helper">{{ gateway.incidentPrimaryAction.helper }}</p>
</section>
<section class="edge-context-panel__section edge-context-panel__section--metrics">
<div class="edge-context-panel__headline">
<div>
<p class="edge-eyebrow">Kanal og backlog</p>
<h4>Kontekst</h4>
</div>
<button
type="button"
class="button is-light is-small"
data-testid="gateway-advanced-toggle"
@click="emit('open-advanced')"
>
{{ advancedOperationsOpen ? "Skjul avanceret" : "Åbn avanceret" }}
</button>
</div>
<dl class="edge-context-panel__metrics">
<div>
<dt>Heartbeat</dt>
<dd>{{ gateway.heartbeatRelative }}</dd>
</div>
<div>
<dt>Backlog</dt>
<dd>{{ gateway.backlogSummary }}</dd>
</div>
<div>
<dt>Fallback</dt>
<dd>{{ gateway.fallback_summary.cloud_relays || 0 }} cloud</dd>
</div>
<div>
<dt>Root shell</dt>
<dd>{{ selectedGatewayShellSummary }}</dd>
</div>
</dl>
</section>
<section class="edge-context-panel__section edge-context-panel__section--activity">
<p class="edge-eyebrow">Seneste aktivitet</p>
<h4>Operatørspor</h4>
<ul class="edge-context-panel__activity">
<li v-for="activity in gateway.recentActivityItems" :key="activity.label">
<strong>{{ activity.label }}</strong>
<span>{{ activity.value }}</span>
<small>{{ activity.helper }}</small>
</li>
</ul>
</section>
<section v-if="departmentScoped" class="edge-context-panel__section edge-context-panel__section--link">
<button
type="button"
class="button is-light edge-context-panel__link"
data-testid="gateway-open-full-page"
@click="emit('open-gateway-page')"
>
Åbn fuld gateway-side
</button>
</section>
</aside>
</template>
@@ -0,0 +1,121 @@
<script setup>
defineProps({
summaryCards: {
type: Array,
default: () => [],
},
fleetFilter: {
type: String,
default: "ALL",
},
fleetQuery: {
type: String,
default: "",
},
selectedGatewayView: {
type: Object,
default: null,
},
filteredGatewayViews: {
type: Array,
default: () => [],
},
initializing: {
type: Boolean,
default: false,
},
fleetDrawerOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"update:fleetFilter",
"update:fleetQuery",
"open-fleet-drawer",
"close-fleet-drawer",
"select-gateway",
]);
</script>
<template>
<div class="edge-fleet-shell">
<div class="edge-mobile-toggle" data-testid="gateway-mobile-toggle">
<button type="button" class="button is-light" data-testid="gateway-mobile-open-rail" @click="emit('open-fleet-drawer')">
Vælg gateway
</button>
<p v-if="selectedGatewayView" class="edge-mobile-toggle__selection">
{{ selectedGatewayView.displayLabel }} · {{ selectedGatewayView.statusLabel }}
</p>
</div>
<button
v-if="fleetDrawerOpen"
type="button"
class="edge-overlay"
aria-label="Luk gatewayliste"
@click="emit('close-fleet-drawer')"
/>
<aside class="edge-fleet-rail" :class="{ 'edge-fleet-rail--open': fleetDrawerOpen }" data-testid="gateway-fleet-rail">
<div class="edge-fleet-rail__header">
<div>
<p class="edge-eyebrow">Gateway-flåde</p>
<h2>Vælg en gateway</h2>
<p>Skift hurtigt mellem drift, lokal styring og gendannelse uden at miste kontekst.</p>
</div>
<button
type="button"
class="delete is-hidden-desktop"
aria-label="Luk gatewayliste"
@click="emit('close-fleet-drawer')"
/>
</div>
<label class="edge-search">
<span>Søg i gateways</span>
<input
:value="fleetQuery"
class="input"
type="search"
data-testid="gateway-fleet-search"
placeholder="Søg gateway, afdeling eller hostnavn"
@input="emit('update:fleetQuery', $event.target.value)"
/>
</label>
<div v-if="initializing" class="edge-empty-state edge-empty-state--rail">
<h3>Indlæser gateway-flåden</h3>
<p>Vi henter de seneste gateways og deres afdelinger.</p>
</div>
<div v-else-if="!filteredGatewayViews.length" class="edge-empty-state edge-empty-state--rail">
<h3>Ingen gateways matcher</h3>
<p>Prøv en anden status eller ryd søgningen for at se hele flåden igen.</p>
</div>
<div v-else class="edge-fleet-list">
<button
v-for="gateway in filteredGatewayViews"
:key="gateway.id"
type="button"
class="edge-fleet-item"
:class="{ 'edge-fleet-item--selected': Number(selectedGatewayView?.id) === Number(gateway.id) }"
:data-selected="Number(selectedGatewayView?.id) === Number(gateway.id) ? 'true' : 'false'"
:data-testid="`gateway-fleet-item-${gateway.id}`"
@click="emit('select-gateway', gateway.id)"
>
<div class="edge-fleet-item__topline">
<strong>{{ gateway.displayLabel }}</strong>
<span class="edge-pill" :data-tone="gateway.statusTone">
{{ gateway.statusLabel }}
</span>
</div>
<p class="edge-fleet-item__meta">{{ gateway.departmentName }} · {{ gateway.hostname }}</p>
<p class="edge-fleet-item__meta">Heartbeat {{ gateway.heartbeatRelative }}</p>
</button>
</div>
</aside>
</div>
</template>
@@ -0,0 +1,135 @@
<script setup>
defineProps({
gateway: {
type: Object,
default: null,
},
selectedGatewayShellSummary: {
type: String,
default: "",
},
});
const emit = defineEmits(["run-primary-action", "open-advanced"]);
</script>
<template>
<section v-if="gateway" class="edge-step-stack" data-testid="gateway-step-assess-panel">
<article class="edge-card">
<header class="edge-card__header edge-card__header--row">
<div>
<p class="edge-eyebrow">Driftsvurdering</p>
<h3>Driftsstatus og næste handling</h3>
</div>
<div class="edge-inline-actions">
<button
type="button"
class="button is-dark edge-button-primary"
data-testid="gateway-step-primary-action"
@click="emit('run-primary-action')"
>
{{ gateway.incidentPrimaryAction.label }}
</button>
<button type="button" class="button is-light" @click="emit('open-advanced')">Avancerede værktøjer</button>
</div>
</header>
<p class="edge-helper-text">{{ gateway.incidentPrimaryAction.helper }}</p>
<dl class="edge-definition-grid">
<div>
<dt>Seneste kommando</dt>
<dd>{{ gateway.lastSuccessfulCommandRelative }}</dd>
</div>
<div>
<dt>Afdeling</dt>
<dd>{{ gateway.departmentName }}</dd>
</div>
<div>
<dt>Gateway transport</dt>
<dd>{{ gateway.transportModeLabel }}</dd>
</div>
<div>
<dt>Afdelingens cutover</dt>
<dd>{{ gateway.departmentTransportModeLabel }}</dd>
</div>
<div>
<dt>Seneste IP</dt>
<dd>{{ gateway.last_seen_ip || "Ikke registreret" }}</dd>
</div>
<div>
<dt>Styringskanal</dt>
<dd>{{ gateway.brokerStatusLabel }}</dd>
</div>
<div>
<dt>Transportstatus</dt>
<dd>{{ gateway.transport_health.status || gateway.statusLabel }}</dd>
</div>
<div>
<dt>Seneste discovery</dt>
<dd>{{ gateway.lastSuccessfulDiscoveryRelative }}</dd>
</div>
<div>
<dt>Seneste root shell</dt>
<dd>{{ gateway.lastSuccessfulShellRelative }}</dd>
</div>
<div>
<dt>Shell status</dt>
<dd>{{ selectedGatewayShellSummary }}</dd>
</div>
</dl>
</article>
<div class="edge-card-grid edge-card-grid--metrics">
<article class="edge-card edge-card--metric">
<p class="edge-eyebrow">Netværk</p>
<h3>Latens</h3>
<strong class="edge-metric-value">{{ gateway.metrics.latency.value }}</strong>
<p class="edge-helper-text">{{ gateway.metrics.latency.helper }}</p>
</article>
<article class="edge-card edge-card--metric">
<p class="edge-eyebrow">System</p>
<h3>CPU</h3>
<strong class="edge-metric-value">{{ gateway.metrics.cpu.value }}</strong>
<p class="edge-helper-text">{{ gateway.metrics.cpu.helper }}</p>
</article>
<article class="edge-card edge-card--metric">
<p class="edge-eyebrow">System</p>
<h3>RAM</h3>
<strong class="edge-metric-value">{{ gateway.metrics.memory.value }}</strong>
<p class="edge-helper-text">{{ gateway.metrics.memory.helper }}</p>
</article>
<article class="edge-card edge-card--metric">
<p class="edge-eyebrow">System</p>
<h3>Disk</h3>
<strong class="edge-metric-value">{{ gateway.metrics.disk.value }}</strong>
<p class="edge-helper-text">{{ gateway.metrics.disk.helper }}</p>
</article>
</div>
<article class="edge-card edge-card--history">
<header class="edge-card__header">
<div>
<p class="edge-eyebrow">Historik</p>
<h3>Seneste gateway-hændelser</h3>
</div>
<p class="edge-card__hint">Audit-loggen giver operatøren hurtig kontekst før næste indgreb.</p>
</header>
<div v-if="!gateway.audit_logs.length" class="edge-empty-state edge-empty-state--inline">
<h3>Ingen historik endnu</h3>
<p>Audit-loggen bliver vist her, når gatewayen har registreret hændelser.</p>
</div>
<ul v-else class="edge-history-list">
<li v-for="entry in gateway.audit_logs.slice(0, 4)" :key="entry.id">
<div>
<strong>{{ entry.action }}</strong>
<p>{{ entry.actor_type }}</p>
</div>
<time :datetime="entry.created_at">{{ entry.created_at }}</time>
</li>
</ul>
</article>
</section>
</template>
@@ -0,0 +1,196 @@
<script setup>
defineProps({
gateway: {
type: Object,
default: null,
},
departments: {
type: Array,
default: () => [],
},
departmentScoped: {
type: Boolean,
default: false,
},
installerDepartmentId: {
type: String,
default: "",
},
installerLabel: {
type: String,
default: "",
},
installerCommand: {
type: String,
default: "",
},
installerDepartmentName: {
type: String,
default: "",
},
installerMeta: {
type: Object,
default: null,
},
installerLoading: {
type: Boolean,
default: false,
},
targetVersion: {
type: String,
default: "",
},
updateLoading: {
type: Boolean,
default: false,
},
advancedOperationsOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"update:installerDepartmentId",
"update:installerLabel",
"update:targetVersion",
"generate-installer",
"copy-installer",
"queue-update",
"toggle-advanced",
]);
</script>
<template>
<section v-if="gateway" class="edge-step-stack" data-testid="gateway-step-recover-panel">
<article class="edge-card">
<header class="edge-card__header edge-card__header--row">
<div>
<p class="edge-eyebrow">Opdateringer</p>
<h3>Planlæg vedligeholdelse</h3>
</div>
<button
type="button"
class="button is-dark edge-button-primary"
:class="{ 'is-loading': updateLoading }"
data-testid="gateway-update-queue"
@click="emit('queue-update')"
>
Planlæg opdatering
</button>
</header>
<div class="edge-form-grid edge-form-grid--recovery">
<label class="field">
<span class="label">Target-version</span>
<input
:value="targetVersion"
class="input"
type="text"
placeholder="F.eks. 1.3.0"
@input="emit('update:targetVersion', $event.target.value)"
/>
</label>
<button type="button" class="button is-light" data-testid="gateway-step-primary-recover" @click="emit('toggle-advanced')">
{{ advancedOperationsOpen ? "Skjul avanceret" : "Åbn avancerede handlinger" }}
</button>
</div>
<div v-if="!gateway.recent_updates.length" class="edge-empty-state edge-empty-state--inline">
<h3>Ingen opdateringer planlagt</h3>
<p>Planlagte update-jobs vises her, når gatewayen indgår i et rollout.</p>
</div>
<ul v-else class="edge-timeline" data-testid="gateway-update-timeline">
<li v-for="job in gateway.recent_updates" :key="job.id" data-testid="gateway-update-item">
<strong>{{ job.target_version }}</strong>
<span>{{ job.statusLabel }}</span>
<small v-if="job.helper">{{ job.helper }}</small>
</li>
</ul>
</article>
<article class="edge-card" data-testid="gateway-installer-card">
<header class="edge-card__header">
<div>
<p class="edge-eyebrow">Installation</p>
<h3>Énlinje-installation til Raspberry Pi</h3>
</div>
<p class="edge-card__hint">
Brug kommandoen en ny Pi afdelingens lokale netværk. Ingen port forwarding kræves.
</p>
</header>
<div class="edge-form-grid edge-form-grid--installer">
<label class="field">
<span class="label">Afdeling</span>
<div class="select is-fullwidth">
<select
:value="installerDepartmentId"
aria-label="Installer afdeling"
:disabled="departmentScoped"
@change="emit('update:installerDepartmentId', $event.target.value)"
>
<option value="">Vælg afdeling</option>
<option v-for="department in departments" :key="department.id" :value="String(department.id)">
{{ department.name }}
</option>
</select>
</div>
</label>
<label class="field">
<span class="label">Gateway-label</span>
<input
:value="installerLabel"
class="input"
type="text"
placeholder="F.eks. Roskilde Pi 01"
@input="emit('update:installerLabel', $event.target.value)"
/>
</label>
<button
type="button"
class="button is-dark edge-button-primary"
:class="{ 'is-loading': installerLoading }"
:disabled="!installerDepartmentId"
data-testid="gateway-installer-generate"
@click="emit('generate-installer')"
>
Generér installer
</button>
<button
type="button"
class="button is-light"
:disabled="!installerCommand"
data-testid="gateway-installer-copy"
@click="emit('copy-installer')"
>
Kopiér kommando
</button>
</div>
<p class="edge-helper-text">
Valgt afdeling: <strong>{{ installerDepartmentName }}</strong>
<span v-if="installerMeta?.expires_at"> · token udløber {{ installerMeta.expires_at }}</span>
</p>
<label class="field">
<span class="label">Installationskommando</span>
<textarea
:value="installerCommand"
class="textarea edge-command"
aria-label="Installationskommando"
rows="4"
readonly
placeholder="Installationskommando vises her, når du genererer en ny token."
/>
</label>
</article>
<slot name="advanced" />
</section>
</template>
@@ -1,4 +1,4 @@
<script setup>
<script setup>
import { computed, nextTick, ref, watch } from "vue";
import { Terminal } from "xterm";
import { FitAddon } from "@xterm/addon-fit";
@@ -134,6 +134,42 @@ const formatFutureTimestamp = (value) => {
};
const sessionMeta = computed(() => session.value ?? props.latestSession ?? null);
const sessionMetadata = computed(() =>
sessionMeta.value?.metadata && typeof sessionMeta.value.metadata === "object" ? sessionMeta.value.metadata : {}
);
const reconnectStateLabel = computed(() => {
const reconnectState = sessionMetadata.value.reconnect_state || "PENDING";
switch (reconnectState) {
case "CONNECTED":
return "forbundet";
case "FAILED":
return "fejlet";
case "CLOSED":
return "lukket";
case "CLOSING":
return "lukker";
default:
return "venter";
}
});
const sessionTransportText = computed(() => {
const transportPath = sessionMetadata.value.transport_path || sessionMetadata.value.transport || "API_POLLING";
return `Transport: ${transportPath} · Reconnect: ${reconnectStateLabel.value}`;
});
const sessionTransportDetail = computed(() => {
if (sessionMetadata.value.last_dispatch_error) {
return `Seneste dispatch-fejl: ${sessionMetadata.value.last_dispatch_error}`;
}
if (sessionMetadata.value.closed_reason) {
return `Lukket fordi: ${sessionMetadata.value.closed_reason}`;
}
return sessionMetadata.value.degraded
? "Sessionen er godkendt, men kører i degraderet tilstand med API polling som fallback."
: "Shell-events leveres fortsat via API polling som holdbar fallback.";
});
const sessionExpiryText = computed(() => {
if (!sessionMeta.value?.expires_at) {
@@ -143,34 +179,30 @@ const sessionExpiryText = computed(() => {
return `Session udløber ${formatFutureTimestamp(sessionMeta.value.expires_at)} (${formatAbsoluteTimestamp(sessionMeta.value.expires_at)})`;
});
const statusLabel = computed(() => {
switch (connectionState.value) {
case EDGE_GATEWAY_TERMINAL_STATES.approving:
return "Godkender";
case EDGE_GATEWAY_TERMINAL_STATES.connecting:
return "Forbinder";
case EDGE_GATEWAY_TERMINAL_STATES.connected:
return "Live";
case EDGE_GATEWAY_TERMINAL_STATES.closed:
return "Lukket";
case EDGE_GATEWAY_TERMINAL_STATES.error:
return "Fejl";
default:
return "Klar";
const terminalStatus = computed(() => {
if (
connectionState.value === EDGE_GATEWAY_TERMINAL_STATES.connected &&
(sessionMetadata.value.degraded || sessionMetadata.value.reconnect_state === "FAILED")
) {
return {
label: "Degraderet",
tone: "warning",
};
}
});
const statusTone = computed(() => {
switch (connectionState.value) {
case EDGE_GATEWAY_TERMINAL_STATES.connected:
return "success";
case EDGE_GATEWAY_TERMINAL_STATES.error:
return "danger";
case EDGE_GATEWAY_TERMINAL_STATES.approving:
return { label: "Afventer godkendelse", tone: "warning" };
case EDGE_GATEWAY_TERMINAL_STATES.connecting:
return "warning";
return { label: "Afventer forbindelse", tone: "warning" };
case EDGE_GATEWAY_TERMINAL_STATES.connected:
return { label: "Live", tone: "success" };
case EDGE_GATEWAY_TERMINAL_STATES.closed:
return { label: "Lukket", tone: "neutral" };
case EDGE_GATEWAY_TERMINAL_STATES.error:
return { label: "Fejlet", tone: "danger" };
default:
return "neutral";
return { label: "Afventer godkendelse", tone: "neutral" };
}
});
@@ -262,7 +294,7 @@ watch(terminalHost, (element) => {
bindinger eller planlagte opdateringer.
</p>
<button type="button" class="button is-warning" data-testid="gateway-shell-open" @click="openPanel">
Åbn root shell-panel
Åbn root shell
</button>
</div>
@@ -282,12 +314,14 @@ watch(terminalHost, (element) => {
<div class="edge-shell-card__meta">
<div class="edge-shell-card__status-copy">
<div class="edge-shell-card__status-row">
<span class="edge-shell-card__badge" :data-tone="statusTone">
{{ statusLabel }}
<span class="edge-shell-card__badge" :data-tone="terminalStatus.tone">
{{ terminalStatus.label }}
</span>
<span class="edge-shell-card__expiry">{{ sessionExpiryText }}</span>
</div>
<p class="edge-shell-card__helper">{{ statusDetail }}</p>
<p class="edge-shell-card__helper">{{ sessionTransportText }}</p>
<p class="edge-shell-card__helper">{{ sessionTransportDetail }}</p>
</div>
<div class="edge-shell-card__actions">
@@ -308,7 +342,7 @@ watch(terminalHost, (element) => {
data-testid="gateway-shell-terminate"
@click="terminateSession"
>
Terminate session
Afslut session
</button>
<button
type="button"
@@ -317,7 +351,7 @@ watch(terminalHost, (element) => {
data-testid="gateway-shell-copy"
@click="copyOutput"
>
Copy output
Kopiér output
</button>
<button
type="button"
@@ -326,7 +360,7 @@ watch(terminalHost, (element) => {
data-testid="gateway-shell-clear"
@click="clearOutput"
>
Clear view
Ryd visning
</button>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,841 @@
/**
* @typedef {"select"|"assess"|"configure"|"recover"} GatewayWorkflowStep
*/
/**
* @typedef {Object} GatewayStepState
* @property {GatewayWorkflowStep} key
* @property {string} eyebrow
* @property {string} label
* @property {string} helper
*/
/**
* @typedef {Object} GatewayIncidentAction
* @property {string} key
* @property {string} label
* @property {string} helper
* @property {GatewayWorkflowStep} targetStep
* @property {"trigger_discovery"|"switch_step"|"open_advanced"} kind
*/
export const STATUS_META = {
ONLINE: {
label: "Online",
tone: "success",
helper: "Svarer normalt og er klar til lokal Shelly-trafik.",
},
DEGRADED: {
label: "Degraderet",
tone: "warning",
helper: "Gatewayen er tilgængelig, men kræver opfølgning.",
},
OFFLINE: {
label: "Offline",
tone: "danger",
helper: "Ingen aktuel heartbeat fra gatewayen.",
},
UNKNOWN: {
label: "Ukendt",
tone: "neutral",
helper: "Status er endnu ikke registreret.",
},
};
export const TRANSPORT_MODE_LABELS = {
gateway: "Lokal gateway",
cloud: "Cloud fallback",
};
export const FALLBACK_MODE_LABELS = {
PREFER_LOCAL: "Foretræk lokal",
LOCAL_ONLY: "Kun lokal",
CLOUD_ONLY: "Kun cloud",
};
export const DISCOVERY_STATUS_LABELS = {
READY: "Klar",
STALE: "Forældet",
PENDING: "Afventer",
FAILED: "Fejlet",
UNKNOWN: "Ukendt",
};
export const UPDATE_STATUS_LABELS = {
PENDING: "Afventer",
DISPATCHING: "Udsendt",
VERIFYING: "Verificerer genstart",
COMPLETED: "Fuldført",
FAILED: "Fejlet",
ROLLED_BACK: "Rullet tilbage",
UNKNOWN: "Ukendt",
};
export const ACTIVE_UPDATE_JOB_STATUSES = ["PENDING", "DISPATCHING", "VERIFYING"];
/** @type {GatewayStepState[]} */
export const WORKFLOW_STEPS = [
{
key: "select",
eyebrow: "Trin 1",
label: "Vælg gateway",
helper: "Skift hurtigt mellem gateways og behold rutehistorikken.",
},
{
key: "assess",
eyebrow: "Trin 2",
label: "Vurder drift",
helper: "Se incident summary, kanalstatus og anbefalet næste handling.",
},
{
key: "configure",
eyebrow: "Trin 3",
label: "Konfigurer lokalt",
helper: "Kør discovery og vedligehold relay-bindinger uden at miste overblik.",
},
{
key: "recover",
eyebrow: "Trin 4",
label: "Gendan og vedligehold",
helper: "Planlæg opdateringer, generér installer og åbn avancerede værktøjer ved behov.",
},
];
export function normalizeGatewayId(value) {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).trim();
return normalized === "" ? null : normalized;
}
export function parseGatewayTimestamp(value) {
if (!value) {
return null;
}
const candidate = typeof value === "string" ? value.replace(" ", "T") : value;
const parsed = new Date(candidate);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
export function formatAbsoluteTimestamp(value) {
const parsed = parseGatewayTimestamp(value);
if (!parsed) {
return "Ikke registreret";
}
return new Intl.DateTimeFormat("da-DK", {
dateStyle: "short",
timeStyle: "short",
}).format(parsed);
}
export function formatRelativeTimestamp(value, now = Date.now()) {
const parsed = parseGatewayTimestamp(value);
if (!parsed) {
return "ikke registreret";
}
const differenceSeconds = Math.max(0, Math.round((now - parsed.getTime()) / 1000));
if (differenceSeconds < 60) {
return "for under 1 min siden";
}
if (differenceSeconds < 3600) {
return `for ${Math.floor(differenceSeconds / 60)} min siden`;
}
if (differenceSeconds < 86400) {
return `for ${Math.floor(differenceSeconds / 3600)} t siden`;
}
return `for ${Math.floor(differenceSeconds / 86400)} dage siden`;
}
export function formatFutureTimestamp(value, now = Date.now()) {
const parsed = parseGatewayTimestamp(value);
if (!parsed) {
return "udløbstid ukendt";
}
const differenceSeconds = Math.max(0, Math.round((parsed.getTime() - now) / 1000));
if (differenceSeconds < 60) {
return "om under 1 min";
}
if (differenceSeconds < 3600) {
return `om ${Math.floor(differenceSeconds / 60)} min`;
}
return `om ${Math.floor(differenceSeconds / 3600)} t`;
}
export function getStatusMeta(status) {
return STATUS_META[status] ?? STATUS_META.UNKNOWN;
}
export function getDepartmentName(departmentId, departmentsById = {}) {
if (!departmentId) {
return "Ikke knyttet til afdeling";
}
return departmentsById[String(departmentId)]?.name ?? `Afdeling #${departmentId}`;
}
export function getControlChannelMeta(gateway) {
const commandChannel = gateway?.channel_status?.command ?? {};
const brokerChannel = gateway?.channel_status?.broker ?? {};
if (gateway?.status === "OFFLINE") {
return {
label: "Polling stoppet",
tone: "warning",
helper: "Gatewayen sender ikke heartbeat nok til at hente kommandoer via API'et.",
};
}
if (commandChannel?.active === "BROKER_FAST_PATH" && brokerChannel?.connected) {
return {
label: "Broker fast path",
tone: commandChannel?.state === "DEGRADED" ? "warning" : "success",
helper:
commandChannel?.state === "DEGRADED"
? "Brokeren er forbundet, men API polling holdes aktiv som sikker fallback."
: "Brokeren leverer kommandoer med API polling som holdbar fallback.",
};
}
return {
label: "API polling",
tone: gateway?.status === "DEGRADED" ? "warning" : "success",
helper:
brokerChannel?.last_error || brokerChannel?.disconnect_reason
? "Broker fast path er nede, så gatewayen kører videre via API polling."
: "Gatewayen henter kommandoer og shell-events via API'et.",
};
}
export function formatRelayHealthReason(reason) {
return (
{
department_cutover: "Afdelingen er tvunget over på cloud",
binding_cloud_only: "Bindingen er låst til cloud",
gateway_offline: "Gateway-heartbeat er udløbet",
device_missing: "Shelly-device mangler i discovery",
device_stale: "Shelly-device er for gammelt i discovery",
device_offline: "Shelly-device er offline",
local_dispatch_failed: "Lokal kommando fejlede og faldt tilbage",
cloud_fallback_failed: "Cloud fallback fejlede",
}[reason] ?? "Ingen aktiv fallback"
);
}
export function normalizeMetricNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
export function formatMetricNumber(value, maximumFractionDigits = 0) {
return new Intl.NumberFormat("da-DK", {
minimumFractionDigits: 0,
maximumFractionDigits,
}).format(value);
}
export function formatMetricPercent(value) {
const parsed = normalizeMetricNumber(value);
if (parsed === null) {
return "Ukendt";
}
return `${formatMetricNumber(parsed, parsed < 10 ? 1 : 0)}%`;
}
export function formatMetricLatency(value) {
const parsed = normalizeMetricNumber(value);
if (parsed === null) {
return "Ukendt";
}
if (parsed < 1000) {
return `${formatMetricNumber(parsed)} ms`;
}
return `${formatMetricNumber(parsed / 1000, 1)} s`;
}
export function formatMetricBytes(value) {
const parsed = normalizeMetricNumber(value);
if (parsed === null || parsed < 0) {
return "Ukendt";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let currentValue = parsed;
let unitIndex = 0;
while (currentValue >= 1024 && unitIndex < units.length - 1) {
currentValue /= 1024;
unitIndex += 1;
}
const maximumFractionDigits = currentValue >= 100 || unitIndex === 0 ? 0 : 1;
return `${formatMetricNumber(currentValue, maximumFractionDigits)} ${units[unitIndex]}`;
}
export function buildGatewayMetricView(gateway) {
const metadata = gateway?.metadata && typeof gateway.metadata === "object" ? gateway.metadata : {};
const systemMetrics =
metadata.system_metrics && typeof metadata.system_metrics === "object" ? metadata.system_metrics : {};
const latencyMs = normalizeMetricNumber(systemMetrics.latency_ms);
const cpuUsagePct = normalizeMetricNumber(systemMetrics.cpu_usage_pct);
const memoryUsagePct = normalizeMetricNumber(systemMetrics.memory_usage_pct);
const memoryUsedBytes = normalizeMetricNumber(systemMetrics.memory_used_bytes);
const memoryTotalBytes = normalizeMetricNumber(systemMetrics.memory_total_bytes);
const diskUsagePct = normalizeMetricNumber(systemMetrics.disk_usage_pct);
const diskUsedBytes = normalizeMetricNumber(systemMetrics.disk_used_bytes);
const diskTotalBytes = normalizeMetricNumber(systemMetrics.disk_total_bytes);
const diskMount =
typeof systemMetrics.disk_mount === "string" && systemMetrics.disk_mount.trim() !== ""
? systemMetrics.disk_mount.trim()
: "/";
return {
latency: {
value: formatMetricLatency(latencyMs),
helper:
latencyMs === null
? "Afventer en netværksmåling fra gatewayen."
: "Rundturstid for gatewayens heartbeat til API'et.",
},
cpu: {
value: formatMetricPercent(cpuUsagePct),
helper:
cpuUsagePct === null ? "CPU-forbrug er ikke rapporteret endnu." : "Samlet CPU-belastning på gatewayen.",
},
memory: {
value: formatMetricPercent(memoryUsagePct),
helper:
memoryUsedBytes !== null && memoryTotalBytes !== null
? `${formatMetricBytes(memoryUsedBytes)} / ${formatMetricBytes(memoryTotalBytes)} brugt`
: "RAM-forbrug er ikke rapporteret endnu.",
},
disk: {
value: formatMetricPercent(diskUsagePct),
helper:
diskUsedBytes !== null && diskTotalBytes !== null
? `${formatMetricBytes(diskUsedBytes)} / ${formatMetricBytes(diskTotalBytes)} brugt på ${diskMount}`
: "Diskforbrug er ikke rapporteret endnu.",
},
};
}
export function buildRelayHealthRows(gateway, bindings = []) {
const relayHealth = Array.isArray(gateway?.relay_health) ? gateway.relay_health : [];
if (relayHealth.length) {
return relayHealth.map((entry) => ({
...entry,
reasonLabel: formatRelayHealthReason(entry?.reason),
fallbackModeLabel:
FALLBACK_MODE_LABELS[entry?.fallback_mode] ?? entry?.fallback_mode ?? FALLBACK_MODE_LABELS.PREFER_LOCAL,
executionPathLabel: entry?.execution_path === "cloud" ? "Cloud fallback" : "Lokal kørsel",
freshnessLabel:
entry?.device_freshness_state === "READY"
? "Frisk"
: entry?.device_freshness_state === "STALE"
? "Forældet"
: entry?.device_freshness_state === "OFFLINE"
? "Offline"
: entry?.device_freshness_state === "MISSING"
? "Mangler"
: "Ukendt",
}));
}
return bindings.map((binding) => ({
binding_id: binding.id,
relay_id: binding.relay_id,
device_id: binding.device_id,
fallback_mode: binding.fallback_mode ?? "PREFER_LOCAL",
fallbackModeLabel: FALLBACK_MODE_LABELS[binding.fallback_mode] ?? FALLBACK_MODE_LABELS.PREFER_LOCAL,
execution_path: "local",
executionPathLabel: "Lokal kørsel",
reason: null,
reasonLabel: "Ingen aktiv fallback",
freshnessLabel: binding.last_success_at ? "Kendt" : "Ukendt",
recommended_action: null,
last_resolution: binding.last_resolution ?? null,
last_error: binding.last_error ?? null,
}));
}
export function normalizeBinding(binding = {}) {
return {
relay_id: binding.relay_id ?? "",
device_id: binding.device_id ?? "",
local_ip: binding.local_ip ?? "",
channel:
binding.channel === "" || binding.channel === null || binding.channel === undefined ? 0 : Number(binding.channel),
binding_source: binding.binding_source ?? "MANUAL",
fallback_mode: binding.fallback_mode ?? "PREFER_LOCAL",
last_resolution: binding.last_resolution ?? null,
last_success_at: binding.last_success_at ?? null,
last_error: binding.last_error ?? null,
};
}
export function formatInventoryChannelCount(count) {
return `${count} kanal${count === 1 ? "" : "er"}`;
}
export function getInventoryGeneration(device = {}) {
return device?.capabilities?.generation ?? device?.metadata?.gen ?? device?.metadata?.generation ?? null;
}
export function buildInventoryOptionLabel(device = {}, { missing = false } = {}) {
const channelCount = Math.max(1, Number(device?.channel_count ?? 1));
const segments = [
device?.model || "Ukendt Shelly-device",
device?.device_id || "Ukendt device-id",
device?.local_ip || "IP ukendt",
formatInventoryChannelCount(channelCount),
];
const generation = getInventoryGeneration(device);
if (generation !== null && generation !== undefined && generation !== "") {
segments.push(`Gen ${generation}`);
}
if (device?.online === false) {
segments.push("Offline");
}
if (missing) {
segments.push("Mangler i discovery");
}
return segments.join(" · ");
}
export function compareInventoryDevices(left, right) {
const leftOfflineRank = left?.online === false ? 1 : 0;
const rightOfflineRank = right?.online === false ? 1 : 0;
if (leftOfflineRank !== rightOfflineRank) {
return leftOfflineRank - rightOfflineRank;
}
const modelComparison = String(left?.model ?? "").localeCompare(String(right?.model ?? ""), "da", {
sensitivity: "base",
});
if (modelComparison !== 0) {
return modelComparison;
}
return String(left?.device_id ?? "").localeCompare(String(right?.device_id ?? ""), "da", {
sensitivity: "base",
});
}
export function buildInventoryDeviceOptions(inventory = []) {
return [...inventory].sort(compareInventoryDevices).map((device) => ({
value: device.device_id,
label: buildInventoryOptionLabel(device),
missing: false,
}));
}
export function buildMissingBindingDevice(binding = {}) {
return {
device_id: binding.device_id ?? "",
local_ip: binding.local_ip ?? "",
model: "Ukendt Shelly-device",
channel_count: Math.max(1, Number(binding.channel ?? 0) + 1),
online: null,
capabilities: {},
metadata: {},
};
}
export function getBindingInventoryDevice(binding, inventory = []) {
const inventoryDevice = inventory.find((device) => device.device_id === binding?.device_id) ?? null;
if (inventoryDevice) {
return inventoryDevice;
}
if (!binding?.device_id) {
return null;
}
return buildMissingBindingDevice(binding);
}
export function buildMissingBindingOption(binding = {}) {
return {
value: binding.device_id,
label: buildInventoryOptionLabel(buildMissingBindingDevice(binding), { missing: true }),
missing: true,
};
}
export function bindingHasStaleSelection(binding, inventoryOptions = []) {
return Boolean(binding?.device_id) && !inventoryOptions.some((device) => device.value === binding.device_id);
}
export function getBindingDeviceOptions(binding, inventoryOptions = []) {
if (!bindingHasStaleSelection(binding, inventoryOptions)) {
return inventoryOptions;
}
return [...inventoryOptions, buildMissingBindingOption(binding)];
}
export function getBindingChannelOptions(binding, inventory = []) {
const device = getBindingInventoryDevice(binding, inventory);
const channelCount = Math.max(1, Number(device?.channel_count ?? 1));
return Array.from({ length: channelCount }, (_, index) => index);
}
export function buildBindingHelperText({ inventoryOptions = [], editableBindings = [] } = {}) {
const hasDiscoveredInventory = inventoryOptions.length > 0;
const hasStaleBindingSelections = editableBindings.some((binding) =>
bindingHasStaleSelection(binding, inventoryOptions)
);
if (hasDiscoveredInventory && hasStaleBindingSelections) {
return "Discovery-listen mangler mindst én gemt device. Vælg en ny device eller behold den markerede, indtil discovery er opdateret.";
}
if (hasDiscoveredInventory) {
return "Arbejd trinvis: bekræft discovery, justér fallback pr. relay, og gem først når kortene ser rigtige ud.";
}
if (hasStaleBindingSelections) {
return "Discovery fandt ingen aktuelle Shelly-enheder, men eksisterende bindinger kan stadig gennemgås og gemmes.";
}
return "Kør discovery for at hente Shelly-enheder, og tilføj derefter bindinger.";
}
export function buildEmptyBindingStateDescription({ inventoryOptions = [] } = {}) {
return inventoryOptions.length > 0
? "Tilføj en binding og map relæer til fundne enheder."
: "Kør discovery først for at hente en opdateret liste over Shelly-enheder.";
}
export function buildSummaryCards(gatewayViews = []) {
const counts = gatewayViews.reduce(
(accumulator, gateway) => {
accumulator.ALL += 1;
if (gateway.status === "ONLINE") {
accumulator.ONLINE += 1;
} else if (gateway.status === "DEGRADED") {
accumulator.DEGRADED += 1;
} else {
accumulator.OFFLINE += 1;
}
return accumulator;
},
{
ALL: 0,
ONLINE: 0,
DEGRADED: 0,
OFFLINE: 0,
}
);
return [
{ key: "ALL", label: "Alle", count: counts.ALL, helper: "Vis hele flåden" },
{ key: "ONLINE", label: "Online", count: counts.ONLINE, helper: "Klar til drift" },
{ key: "DEGRADED", label: "Degraderet", count: counts.DEGRADED, helper: "Kræver opfølgning" },
{ key: "OFFLINE", label: "Offline", count: counts.OFFLINE, helper: "Ingen heartbeat" },
];
}
export function filterGatewayViews(gatewayViews = [], { fleetFilter = "ALL", query = "" } = {}) {
const normalizedQuery = String(query).trim().toLowerCase();
return gatewayViews.filter((gateway) => {
const matchesStatus =
fleetFilter === "ALL" ||
(fleetFilter === "OFFLINE" && gateway.status !== "ONLINE" && gateway.status !== "DEGRADED") ||
gateway.status === fleetFilter;
const matchesQuery =
normalizedQuery.length === 0 ||
[gateway.displayLabel, gateway.hostname, gateway.departmentName, gateway.last_seen_ip]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(normalizedQuery));
return matchesStatus && matchesQuery;
});
}
export function resolveIncidentPrimaryAction(gateway) {
const recommendedAction =
gateway?.transport_health?.recommended_action ?? gateway?.fallback_summary?.recommended_action ?? null;
switch (recommendedAction) {
case "retry_discovery":
return {
key: "run-discovery",
label: "Kør discovery nu",
helper: "Opdater de lokale Shelly-enheder og deres friskhed.",
targetStep: "configure",
kind: "trigger_discovery",
};
case "review_binding_override":
return {
key: "review-bindings",
label: "Gennemgå bindinger",
helper: "Se fallback-politikker og justér relæernes lokale mapping.",
targetStep: "configure",
kind: "switch_step",
};
case "review_department_cutover":
return {
key: "review-cutover",
label: "Gennemgå avancerede handlinger",
helper: "Kontrollér afdelingens cutover og andre nødindgreb.",
targetStep: "recover",
kind: "open_advanced",
};
case "restart_agent":
return {
key: "review-maintenance",
label: "Åbn vedligeholdelse",
helper: "Se installer, opdateringer og root shell-værktøjer samlet.",
targetStep: "recover",
kind: "switch_step",
};
default:
if (gateway?.status === "OFFLINE") {
return {
key: "review-maintenance",
label: "Åbn vedligeholdelse",
helper: "Gatewayen er offline. Gå direkte til recovery-værktøjerne.",
targetStep: "recover",
kind: "switch_step",
};
}
return {
key: "review-health",
label: "Se driftsoverblik",
helper: "Bekræft kanalstatus, discovery og fallback før du ændrer noget.",
targetStep: "assess",
kind: "switch_step",
};
}
}
export function buildRecentActivityItems(gatewayView) {
if (!gatewayView) {
return [];
}
return [
{
label: "Heartbeat",
value: gatewayView.heartbeatRelative,
helper: gatewayView.heartbeatAbsolute,
},
{
label: "Discovery",
value: gatewayView.lastSuccessfulDiscoveryRelative,
helper: gatewayView.discoveryStatusLabel,
},
{
label: "Shell",
value: gatewayView.lastSuccessfulShellRelative,
helper: gatewayView.recent_shell_sessions?.[0]?.reason || "Ingen aktiv session",
},
{
label: "Seneste opdatering",
value: gatewayView.recent_updates?.[0]?.target_version || "Ingen planlagt",
helper: gatewayView.recent_updates?.[0]?.statusLabel || "Ingen opdateringer",
},
];
}
export function buildGatewayView(gateway, { departmentNameById = {}, now = Date.now() } = {}) {
if (!gateway) {
return null;
}
const metadata = gateway.metadata && typeof gateway.metadata === "object" ? gateway.metadata : {};
const statusMeta = getStatusMeta(gateway.status);
const inventory = Array.isArray(gateway.inventory) ? gateway.inventory : [];
const bindings = Array.isArray(gateway.bindings) ? gateway.bindings : [];
const recentUpdates = (Array.isArray(gateway.recent_updates) ? gateway.recent_updates : []).map((job) => {
const result = job?.result && typeof job.result === "object" ? job.result : {};
const status = job?.status ?? "UNKNOWN";
return {
...job,
result,
statusLabel: UPDATE_STATUS_LABELS[status] ?? status ?? UPDATE_STATUS_LABELS.UNKNOWN,
helper:
result?.error ||
(status === "VERIFYING"
? "Den nye agent er installeret og venter på at bekræfte genstarten."
: status === "ROLLED_BACK"
? "Agenten blev rullet tilbage efter en fejl under opdateringen."
: ""),
};
});
const recentShellSessions = Array.isArray(gateway.recent_shell_sessions) ? gateway.recent_shell_sessions : [];
const recentCommands = Array.isArray(gateway.recent_commands) ? gateway.recent_commands : [];
const auditLogs = Array.isArray(gateway.audit_logs) ? gateway.audit_logs : [];
const channelStatus = gateway.channel_status && typeof gateway.channel_status === "object" ? gateway.channel_status : {};
const fallbackSummary =
gateway.fallback_summary && typeof gateway.fallback_summary === "object" ? gateway.fallback_summary : {};
const transportHealth =
gateway.transport_health && typeof gateway.transport_health === "object" ? gateway.transport_health : {};
const brokerMeta = getControlChannelMeta(gateway);
const metrics = buildGatewayMetricView(gateway);
const relayHealth = buildRelayHealthRows(gateway, bindings);
const incidentPrimaryAction = resolveIncidentPrimaryAction({
...gateway,
transport_health: transportHealth,
fallback_summary: fallbackSummary,
});
const gatewayView = {
...gateway,
metadata,
inventory,
bindings,
channel_status: channelStatus,
fallback_summary: fallbackSummary,
transport_health: transportHealth,
relay_health: relayHealth,
recent_updates: recentUpdates,
recent_shell_sessions: recentShellSessions,
recent_commands: recentCommands,
audit_logs: auditLogs,
statusLabel: statusMeta.label,
statusTone: statusMeta.tone,
statusHelper: statusMeta.helper,
departmentName: getDepartmentName(gateway.department_id, departmentNameById),
transportModeLabel: TRANSPORT_MODE_LABELS[gateway.transport_mode] ?? gateway.transport_mode ?? "Ukendt",
departmentTransportModeLabel:
TRANSPORT_MODE_LABELS[gateway.department_transport_mode] ??
gateway.department_transport_mode ??
"Ukendt",
hasTransportOverride:
(gateway.transport_mode ?? null) !== null &&
(gateway.department_transport_mode ?? null) !== null &&
gateway.transport_mode !== gateway.department_transport_mode,
brokerStatusLabel: brokerMeta.label,
brokerStatusTone: brokerMeta.tone,
brokerStatusHelper: brokerMeta.helper,
metrics,
discoveryStatusLabel:
DISCOVERY_STATUS_LABELS[gateway.discovery_status] ?? gateway.discovery_status ?? DISCOVERY_STATUS_LABELS.UNKNOWN,
incidentSummary:
transportHealth?.summary ??
(fallbackSummary?.cloud_relays
? `${fallbackSummary.cloud_relays} relæ(er) kører via cloud fallback`
: "Ingen aktive fallback-hændelser."),
incidentAction:
transportHealth?.recommended_action ?? fallbackSummary?.recommended_action ?? brokerMeta.helper ?? "",
incidentPrimaryAction,
displayLabel: gateway.label || gateway.hostname || `Gateway #${gateway.id}`,
heartbeatRelative: formatRelativeTimestamp(gateway.last_heartbeat_at, now),
heartbeatAbsolute: formatAbsoluteTimestamp(gateway.last_heartbeat_at),
lastSuccessfulCommandRelative: formatRelativeTimestamp(gateway.last_successful_command_at, now),
lastSuccessfulCommandAbsolute: formatAbsoluteTimestamp(gateway.last_successful_command_at),
lastSuccessfulDiscoveryRelative: formatRelativeTimestamp(gateway.last_successful_discovery_at, now),
lastSuccessfulShellRelative: formatRelativeTimestamp(gateway.last_successful_shell_at, now),
backlogSummary: [
`${gateway.backlog_depth?.commands ?? 0} kommandoer`,
`${gateway.backlog_depth?.shell_actions ?? 0} shell`,
`${gateway.backlog_depth?.updates ?? 0} opdateringer`,
].join(" · "),
};
gatewayView.recentActivityItems = buildRecentActivityItems(gatewayView);
return gatewayView;
}
export function getSelectedGatewayShellSummary(gatewayView, now = Date.now()) {
const session = gatewayView?.recent_shell_sessions?.[0];
if (!session?.expires_at) {
return "Ingen aktiv root shell-session";
}
return `Seneste session udløber ${formatFutureTimestamp(session.expires_at, now)}`;
}
export function buildWorkflowStepModels({ activeStep = "select", hasGateway = false } = {}) {
return WORKFLOW_STEPS.map((step) => ({
...step,
active: step.key === activeStep,
disabled: !hasGateway && step.key !== "select",
}));
}
export function resolveNextWorkflowStep(currentStep, hasGateway) {
if (!hasGateway) {
return "select";
}
if (currentStep && WORKFLOW_STEPS.some((step) => step.key === currentStep && step.key !== "select")) {
return currentStep;
}
return "assess";
}
export function mergeWorkspaceDraftState({
gateway,
currentState = {},
dirtyState = {},
force = false,
} = {}) {
const nextBindings = (gateway?.bindings ?? []).map((binding) => normalizeBinding(binding));
const nextTargetVersion = gateway?.target_version ?? gateway?.installed_version ?? "";
const nextTransportMode = gateway?.department_transport_mode ?? gateway?.transport_mode ?? "gateway";
if (force) {
return {
editableBindings: nextBindings,
targetVersion: nextTargetVersion,
transportMode: nextTransportMode,
};
}
return {
editableBindings: dirtyState.bindings ? currentState.editableBindings ?? [] : nextBindings,
targetVersion: dirtyState.targetVersion ? currentState.targetVersion ?? "" : nextTargetVersion,
transportMode: dirtyState.transportMode ? currentState.transportMode ?? "gateway" : nextTransportMode,
};
}
export function resolveEdgeGatewayErrorMessage(error, fallbackMessage) {
const message =
error?.response?.data?.data?.message ??
error?.response?.data?.message ??
error?.response?.data?.error ??
error?.message ??
"";
if (typeof message !== "string" || message.trim() === "") {
return fallbackMessage;
}
if (message.includes("Gateway agent is offline")) {
return "Gateway-agenten er offline og kan ikke hente nye kommandoer lige nu.";
}
return message;
}
+20
View File
@@ -37,6 +37,22 @@ function wait(delayMs) {
});
}
function clearDocumentSelection() {
const selection = window.getSelection?.();
if (!selection) {
return;
}
if (typeof selection.removeAllRanges === "function") {
selection.removeAllRanges();
return;
}
if (typeof selection.empty === "function") {
selection.empty();
}
}
export function useEdgeGatewayTerminal({
pollSessionEvents,
sendSessionInput,
@@ -272,7 +288,11 @@ export function useEdgeGatewayTerminal({
statusDetail.value = "Live shell er forbundet og klar til input.";
connectionError.value = "";
appendNotice("[Live shell forbundet]");
clearDocumentSelection();
terminal?.focus?.();
window.setTimeout(() => {
clearDocumentSelection();
}, 0);
fitTerminal();
return;
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -21,6 +21,12 @@ export const saveEdgeGatewayBindings = async (gatewayId, bindings) =>
export const createEdgeGatewayUpdateJob = async (gatewayId, payload) =>
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/update-jobs`, "POST", payload);
export const queueEdgeGatewayUninstall = async (gatewayId) =>
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/uninstall`, "POST", {});
export const deleteEdgeGateway = async (gatewayId) =>
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}`, "DELETE", {});
export const createEdgeGatewayShellSession = async (gatewayId, payload) =>
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/shell-sessions`, "POST", payload);
@@ -1,4 +1,4 @@
<script setup>
<script setup>
import { computed } from "vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
@@ -1,4 +1,4 @@
<script setup>
<script setup>
import { computed } from "vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
@@ -31,7 +31,7 @@ const openGatewayPage = async (gatewayId) => {
<template #title>
<PageTitle
title="Gateway for afdelingen"
subtitle="Styr lokal Shelly-routing, bindinger, opdateringer og break-glass shell for afdelingen"
subtitle="Styr lokal Shelly-routing, bindinger, opdateringer og nødadgang til shell for afdelingen"
/>
</template>
<EdgeGatewayWorkspace :department-id="departmentId" @open-gateway-page="openGatewayPage" />
+2 -1
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isCompactProject } from "./support/projects";
const adminPermissions = [
"admin",
@@ -179,7 +180,7 @@ async function mockDailyReportDependencies(
}
async function expandDepartmentFiltersOnMobile(page, testInfo) {
if (!testInfo.project.use.isMobile) {
if (!isCompactProject(testInfo)) {
return;
}
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isCompactProject, isDesktopProject } from "./support/projects";
const adminPermissions = [
"admin",
@@ -96,7 +97,7 @@ async function mockAdminDepartmentDependencies(page, options: { departmentDelayM
}
async function expandDepartmentFiltersOnMobile(page, testInfo) {
if (!testInfo.project.use.isMobile) {
if (!isCompactProject(testInfo)) {
return;
}
@@ -125,7 +126,7 @@ test.describe("Admin department visibility", () => {
await expect(departmentControls.getByRole("button", { name: "Hidden South" })).toHaveCount(0);
await expect(departmentControls.getByRole("button", { name: "Numeric Hidden" })).toHaveCount(0);
if (!test.info().project.name.includes("mobile")) {
if (isDesktopProject(test.info())) {
await expect(page.locator("option", { hasText: "Visible North" })).toHaveCount(1);
await expect(page.locator("option", { hasText: "Legacy East" })).toHaveCount(1);
await expect(page.locator("option", { hasText: "Hidden South" })).toHaveCount(0);
+2 -1
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isCompactProject } from "./support/projects";
const adminPermissions = [
"admin",
@@ -112,7 +113,7 @@ test.describe("Admin overview mobile", () => {
test("keeps modules first, collapses reports by default, and preserves mobile report actions", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.use.isMobile, "Mobile-only overview regression");
test.skip(!isCompactProject(testInfo), "Compact-device overview regression");
await mockOverviewPageDependencies(page);
await page.goto("/admin/1");
+17 -6
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { expect, test, Page } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isCompactProject } from "./support/projects";
const adminPermissions = [
"admin",
@@ -317,7 +318,7 @@ async function mockOverviewDependencies(page) {
}
async function expandOverviewReportsOnMobile(page, testInfo) {
if (!testInfo.project.use.isMobile) {
if (!isCompactProject(testInfo)) {
return;
}
@@ -329,6 +330,18 @@ async function expandOverviewReportsOnMobile(page, testInfo) {
await expect(page.getByTestId("daily-report-page")).toBeVisible();
}
async function setOverviewEndDate(page: Page, value: string) {
const endDateInput = page
.locator('[data-testid="daily-report-date-controls"] [data-testid="date-period-end"]')
.last();
await endDateInput.evaluate((element, nextValue) => {
const input = element as HTMLInputElement;
input.value = nextValue;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
}, value);
}
test.describe("Admin overview night washes", () => {
test("renders deduped Døgnvask totals across cards, list, and chart with stable range updates", async ({
page,
@@ -359,9 +372,7 @@ test.describe("Admin overview night washes", () => {
await expect(page.getByTestId("outside-hours-chart")).toBeVisible();
await expect(page.getByTestId("outside-hours-chart-warning")).toContainText("Missing Hours");
const endDateInput = page.getByTestId("daily-report-date-controls").locator("input[type='date']:visible").nth(1);
await endDateInput.fill("2026-03-25");
await endDateInput.blur();
await setOverviewEndDate(page, "2026-03-25");
await expect(page).toHaveURL(/dateTo=2026-03-25/);
await tabs.nth(0).click();
@@ -377,7 +388,7 @@ test.describe("Admin overview night washes", () => {
await tabs.nth(2).click();
await expect(page.getByTestId("outside-hours-chart")).toBeVisible();
if (testInfo.project.use.isMobile) {
if (isCompactProject(testInfo)) {
await tabs.nth(0).click();
await expect(page.getByTestId("daily-report-tile-night-washes")).toBeVisible();
await tabs.nth(1).click();
+70 -18
View File
@@ -13,13 +13,47 @@ const POS_PERMISSIONS = [
"get_custom_prices_other",
];
const POS_BOOT_URL = "/admin/12/modules/pos?step=1";
function getPosBootStep(page: Page) {
return page.locator('[data-testid="pos-step-1"]:visible, [data-testid="pos-mobile-step-1"]:visible').first();
}
async function navigateToPosBootPage(page: Page, { waitForSession = false }: { waitForSession?: boolean } = {}) {
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
const sessionRequest = waitForSession
? page
.waitForResponse(
(response) => response.request().method() === "GET" && response.url().includes("/auth/session"),
{ timeout: 10_000 }
)
.catch(() => null)
: Promise.resolve(null);
try {
await page.goto(POS_BOOT_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
await sessionRequest;
await expect(getPosBootStep(page)).toBeVisible({ timeout: 10_000 });
return;
} catch (error) {
lastError = error;
if (attempt === 2) {
throw error;
}
await page.goto("about:blank").catch(() => {});
}
}
throw lastError;
}
async function primeOperatorSession(page: Page, token = "pos-orders-token", _permissions = POS_PERMISSIONS) {
await seedAuthenticatedState(page, token);
const sessionRequest = page.waitForResponse((response) => {
return response.request().method() === "GET" && response.url().includes("/auth/session");
});
await page.goto("/login");
await sessionRequest;
await navigateToPosBootPage(page, { waitForSession: true });
}
async function createDisposableOrder(page: Page) {
@@ -101,8 +135,12 @@ async function openOrderSettings(page: Page, orderId: number) {
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
}
async function revisitCurrentPage(page: Page) {
await page.goto(page.url(), { waitUntil: "domcontentloaded" });
}
async function reloadOrderSettings(page: Page, orderId: number) {
await page.reload();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await clickVisibleTestId(page, "pos-order-tab-settings");
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
@@ -203,8 +241,21 @@ async function changeOrderInvoiceCollection(page: Page, closedAt: string) {
}
async function openOrderDetail(page: Page, orderId = 54518) {
await page.goto(`/admin/12/modules/pos/orders/${orderId}`);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
const orderUrl = `/admin/12/modules/pos/orders/${orderId}`;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await page.goto(orderUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
await expect(page.getByTestId("pos-order-detail")).toBeVisible({ timeout: 10_000 });
return;
} catch (error) {
if (attempt === 1) {
throw error;
}
await navigateToPosBootPage(page);
}
}
}
async function openOrderAttachments(page: Page, orderId = 54518) {
@@ -578,7 +629,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
expect(Math.abs((restoredMainBox?.width ?? 0) - (beforeMainBox?.width ?? 0))).toBeLessThanOrEqual(20);
expect(Math.abs((beforeRailBox?.width ?? 0) - (restoredRailBox?.width ?? 0))).toBeLessThanOrEqual(20);
await page.reload({ waitUntil: "domcontentloaded" });
await page.goto("/admin/12/modules/pos/orders/54518", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
await expectOrderTotal(page, 1397);
@@ -593,6 +644,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(page.getByTestId("pos-step-1").locator(".pos-card-tabs > .tabs li.is-active")).toContainText("Kunde");
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
await expect(page).toHaveURL(/\/admin\/12\/modules\/pos\?id=\d+&customer_id=12345679&step=2/);
const stepTwoUrl = page.url();
const stepTwo = page.getByTestId("pos-step-2");
const orderCard = stepTwo.getByTestId("pos-order-card");
@@ -627,7 +679,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
);
expect(Math.abs((customerWishesCardBox?.x ?? 0) - (licensePlatesCardBox?.x ?? 0))).toBeLessThanOrEqual(2);
await page.reload({ waitUntil: "domcontentloaded" });
await page.goto(stepTwoUrl, { waitUntil: "domcontentloaded" });
await expect(stepTwo).toBeVisible();
await expect(page).toHaveURL(/\/admin\/12\/modules\/pos\?id=\d+&customer_id=12345679&step=2/);
await expect(getVisibleTestId(page, "pos-order-registration-1")).toContainText("EC21235");
@@ -695,7 +747,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
});
await expect(noteInput).toBeFocused();
await page.reload();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(referenceValue);
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(poValue);
@@ -748,7 +800,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
po: "",
});
await page.reload();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-customer-wishes-reference")).toContainText("+ Tilføj");
await expect(page.getByTestId("pos-order-customer-wishes-po")).toContainText("+ Tilføj");
@@ -809,7 +861,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
});
await expect(reg3Input).toHaveValue("NO9012", { timeout: 10000 });
await page.reload();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-registration-1")).toContainText("XY1234");
await expect(page.getByTestId("pos-order-registration-2")).toContainText("TR5678");
@@ -837,7 +889,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(page.getByTestId("pos-order-item-price-9101")).toContainText("1400");
await expectOrderTotal(page, 2123);
await page.reload();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-item-note-trigger-9101")).toBeVisible();
await expect(page.getByTestId("pos-order-item-reference-trigger-9101")).toBeVisible();
@@ -862,7 +914,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(page.getByTestId("pos-order-item-name-9102")).toContainText("Indvendig vask Forvogn");
await expectOrderTotal(page, 1423);
await page.reload();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-item-note-trigger-9102")).toBeVisible();
await expect(page.getByTestId("pos-order-item-price-9102")).toContainText("450");
@@ -912,7 +964,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(page.getByTestId("pos-order-item-delete-9103")).toHaveCount(0);
await expectOrderTotal(page, 0);
await page.reload();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-empty-state")).toBeVisible();
await expectOrderTotal(page, 0);
@@ -935,7 +987,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(page.getByTestId("pos-order-item-name-9200").first()).toContainText("Forvogn");
await expectOrderTotal(page, 2021);
await page.reload();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(page.getByTestId("pos-order-item-edit-9200").first()).toBeVisible();
await expectOrderTotal(page, 2021);
@@ -1856,7 +1908,7 @@ test.describe("Admin POS Orders - mobile smoke", () => {
expect(restoredMainBox).not.toBeNull();
expect(Math.abs((restoredMainBox?.width ?? 0) - (beforeMainBox?.width ?? 0))).toBeLessThanOrEqual(20);
await page.reload();
await revisitCurrentPage(page);
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
await expectOrderTotal(page, 1397);
+37 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
async function primeSuperuserSession(page) {
@@ -12,6 +12,14 @@ async function openFleetRail(page, testInfo) {
}
}
async function openRecoverStep(page) {
await page.getByTestId("gateway-step-recover").click();
}
async function openAdvancedOperations(page) {
await page.getByTestId("gateway-step-primary-recover").click();
}
test.describe("Edge gateway route-aware flows", () => {
test.beforeEach(async ({ page }) => {
await mockApi(page, {
@@ -60,6 +68,8 @@ test.describe("Edge gateway route-aware flows", () => {
test("reveals and copies the rotated agent token after an explicit confirmation step", async ({ page }) => {
await page.goto("/superuser/gateways/701");
await openRecoverStep(page);
await openAdvancedOperations(page);
await page.getByTestId("gateway-rotate-credentials").click();
await expect(page.getByTestId("gateway-rotation-confirmation")).toBeVisible();
@@ -83,6 +93,31 @@ test.describe("Edge gateway route-aware flows", () => {
await expect.poll(() => page.evaluate(() => window.__copiedGatewayToken)).toBe("rotated-token-701");
});
test("queues uninstall for an online Pi and deletes an offline gateway from the canonical fleet view", async ({
page,
}) => {
await page.goto("/superuser/gateways/701");
await openRecoverStep(page);
await openAdvancedOperations(page);
await page.getByTestId("gateway-uninstall").click();
await expect(page.getByTestId("gateway-uninstall-confirmation")).toBeVisible();
await page.getByTestId("gateway-uninstall-confirm").click();
await expect(page.locator("body")).toContainText("Afinstallering er planlagt");
await page.goto("/superuser/gateways/702");
await openRecoverStep(page);
await openAdvancedOperations(page);
await expect(page.getByTestId("gateway-uninstall")).toBeDisabled();
await page.getByTestId("gateway-delete").click();
await expect(page.getByTestId("gateway-delete-confirmation")).toBeVisible();
await page.getByTestId("gateway-delete-confirm").click();
await expect(page).toHaveURL(/\/superuser\/gateways\/701$/);
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
});
test("links the department-scoped gateway manager into the canonical gateway detail page", async ({ page }) => {
await page.goto("/superuser/departments/1/gateways");
@@ -121,6 +156,7 @@ test.describe("Edge gateway background refresh", () => {
await primeSuperuserSession(page);
await page.goto("/superuser/gateways/701");
await openRecoverStep(page);
const updateTimeline = page.getByTestId("gateway-update-timeline");
const targetVersionField = page.getByLabel("Target-version");
+114 -30
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
async function primeSuperuserSession(page) {
@@ -6,8 +6,21 @@ async function primeSuperuserSession(page) {
await primeMockSession(page, { token });
}
async function openRecoverStep(page) {
await page.getByTestId("gateway-step-recover").click();
}
async function openConfigureStep(page) {
await page.getByTestId("gateway-step-configure").click();
}
async function openAdvancedOperations(page) {
await page.getByTestId("gateway-step-primary-recover").click();
await expect(page.getByTestId("gateway-advanced-operations")).toContainText("Break-glass");
}
test.describe("Edge gateway management smoke", () => {
test("@smoke manages installers, bindings, queued updates, and shell approval", async ({ page }) => {
test("@smoke manages the guided workflow across health, local control, maintenance, and shell approval", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
@@ -18,9 +31,8 @@ test.describe("Edge gateway management smoke", () => {
await expect(page).toHaveURL(/\/superuser\/gateways$/);
await expect(page.locator("body")).toContainText("Edge gateways");
await expect(page.locator("body")).toContainText("Valgt gateway");
await expect(page.locator("body")).toContainText("CPH Edge 01");
await expect(page.locator("body")).toContainText("Shelly Plus 2PM");
await expect(page.getByTestId("gateway-step-shell-nav")).toContainText("Kategorier");
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
await expect(page.locator("body")).toContainText("Latens");
await expect(page.locator("body")).toContainText("184 ms");
await expect(page.locator("body")).toContainText("CPU");
@@ -30,12 +42,16 @@ test.describe("Edge gateway management smoke", () => {
await expect(page.locator("body")).toContainText("Disk");
await expect(page.locator("body")).toContainText("58%");
const bindingRows = page.locator(".edge-table--editor tbody tr");
const primaryBindingRow = bindingRows.nth(0);
const legacyBindingRow = bindingRows.nth(1);
const primaryDeviceSelect = primaryBindingRow.locator("select").first();
const primaryChannelSelect = primaryBindingRow.locator("select").nth(1);
const primaryIpInput = primaryBindingRow.locator('input[type="text"]').nth(1);
await openConfigureStep(page);
await expect(page.getByTestId("gateway-step-configure-panel")).toContainText("Shelly Plus 2PM");
const bindingCards = page.locator(".edge-binding-card");
const primaryBindingCard = bindingCards.nth(0);
const legacyBindingCard = bindingCards.nth(1);
const primaryDeviceSelect = primaryBindingCard.locator("select").nth(0);
const primaryChannelSelect = primaryBindingCard.locator("select").nth(1);
const primaryFallbackSelect = primaryBindingCard.locator("select").nth(2);
const primaryIpInput = primaryBindingCard.getByLabel("IP");
const primaryOnlineOption = primaryDeviceSelect.locator("option").nth(1);
await expect(primaryOnlineOption).toContainText("Shelly Plus 2PM");
@@ -52,7 +68,7 @@ test.describe("Edge gateway management smoke", () => {
await expect(primaryOfflineOption).toContainText("Gen 3");
await expect(primaryOfflineOption).toContainText("Offline");
const staleOption = legacyBindingRow.locator("select").first().locator("option").last();
const staleOption = legacyBindingCard.getByLabel("Shelly-device").locator("option").last();
await expect(staleOption).toContainText("Ukendt Shelly-device");
await expect(staleOption).toContainText("shelly-missing-legacy");
await expect(staleOption).toContainText("10.1.0.99");
@@ -61,36 +77,46 @@ test.describe("Edge gateway management smoke", () => {
await primaryChannelSelect.selectOption("1");
await primaryDeviceSelect.selectOption("shelly-mini-offline");
await primaryFallbackSelect.selectOption("LOCAL_ONLY");
await expect(primaryIpInput).toHaveValue("10.1.0.34");
await expect(primaryChannelSelect).toHaveValue("0");
await expect(primaryFallbackSelect).toHaveValue("LOCAL_ONLY");
const relayBindingInput = primaryBindingRow.locator('input[type="text"]').first();
const relayBindingInput = primaryBindingCard.getByLabel("Relæ-id");
await relayBindingInput.fill("M-7-CANARY");
await page.getByRole("button", { name: "Gem bindinger" }).click();
await expect(relayBindingInput).toHaveValue("M-7-CANARY");
await expect(primaryDeviceSelect).toHaveValue("shelly-mini-offline");
await expect(primaryIpInput).toHaveValue("10.1.0.34");
await expect(primaryChannelSelect).toHaveValue("0");
await expect(primaryFallbackSelect).toHaveValue("LOCAL_ONLY");
await page.getByLabel("Installer afdeling").selectOption("1");
await page.locator('input[placeholder="F.eks. Roskilde Pi 01"]').fill("Canary Pi");
await page.getByRole("button", { name: /installer/i }).click();
await expect(page.getByLabel("Installationskommando")).toHaveValue(/curl -fsSL/);
await page.getByRole("button", { name: /discovery/i }).click();
await page.getByTestId("gateway-discovery-action").click();
await expect(page.locator("body")).toContainText("Discovery er");
await expect(page.locator("body")).toContainText("Afventer");
await expect(page.locator("body")).toContainText("shelly-plus-new");
await openRecoverStep(page);
await page.getByLabel("Installer afdeling").selectOption("1");
await page.locator('input[placeholder="F.eks. Roskilde Pi 01"]').fill("Canary Pi");
await page.getByTestId("gateway-installer-generate").click();
await expect(page.getByLabel("Installationskommando")).toHaveValue(/curl -fsSL/);
await page.locator('input[placeholder="F.eks. 1.3.0"]').fill("1.3.0");
await page.getByRole("button", { name: /opdatering/i }).click();
await page.getByTestId("gateway-update-queue").click();
await expect(page.locator("body")).toContainText("Opdatering er");
const latestUpdate = page.getByTestId("gateway-update-item").first();
await expect(latestUpdate).toContainText("1.3.0");
await expect(latestUpdate).toContainText("Verificerer genstart");
await expect(latestUpdate).toContainText("Fuldført");
await page.getByRole("button", { name: /root shell/i }).click();
await openAdvancedOperations(page);
await page.getByRole("button", { name: "Vis tabel" }).click();
await expect(page.getByTestId("gateway-relay-health-table")).toContainText("M-7-LEGACY");
await expect(page.getByTestId("gateway-relay-health-table")).toContainText("Cloud fallback");
await expect(page.getByTestId("gateway-relay-health-table")).toContainText("Kun cloud");
await page.getByTestId("gateway-shell-open").click();
await page.getByRole("textbox", { name: /begrundelse/i }).fill("Investigate offline relay");
await page.getByRole("button", { name: "Godkend root shell" }).click();
@@ -98,6 +124,9 @@ test.describe("Edge gateway management smoke", () => {
await expect(page.locator("body")).toContainText("Live");
await expect(terminalBuffer).toContainText("[Live shell forbundet]");
await expect(terminalBuffer).toContainText("root@pi:~#");
await expect
.poll(() => page.evaluate(() => window.getSelection?.()?.toString() ?? ""))
.toBe("");
const terminalSurface = page.locator('[aria-label="Root shell terminal"]');
await terminalSurface.focus();
@@ -106,7 +135,7 @@ test.describe("Edge gateway management smoke", () => {
await terminalSurface.press("Enter");
await expect(terminalBuffer).toContainText("agent.mjs");
await page.getByRole("button", { name: "Terminate session" }).click();
await page.getByRole("button", { name: "Afslut session" }).click();
await expect(page.locator("body")).toContainText("Lukket");
await expect(terminalBuffer).toContainText("Session lukket");
});
@@ -124,19 +153,68 @@ test.describe("Edge gateway management smoke", () => {
await primeSuperuserSession(page);
await page.goto("/superuser/gateways");
await page.getByRole("button", { name: /root shell/i }).click();
await openRecoverStep(page);
await openAdvancedOperations(page);
await page.getByTestId("gateway-shell-open").click();
await page.getByRole("textbox", { name: /begrundelse/i }).fill("Test broken shell");
await page.getByRole("button", { name: "Godkend root shell" }).click();
await expect(page.locator("body")).toContainText("Fejl");
await expect(page.getByRole("button", { name: "Terminate session" })).toBeDisabled();
await expect(page.locator("body")).toContainText("Fejlet");
await expect(page.getByRole("button", { name: "Afslut session" })).toBeDisabled();
await expect(page.locator('[aria-label="Root shell buffer"]')).toContainText("Gateway-shell blev lukket");
});
test("@smoke applies department cutover mode from department page", async ({ page }) => {
test("@smoke applies department cutover mode from the department page", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
gatewayOverrides: [
{
id: 701,
metadata: {
broker_connected: false,
system_metrics: {
latency_ms: 184,
cpu_usage_pct: 27,
memory_usage_pct: 61,
memory_used_bytes: 2576980377,
memory_total_bytes: 4294967296,
disk_usage_pct: 58,
disk_used_bytes: 249108103168,
disk_total_bytes: 429496729600,
disk_mount: "/",
},
},
channel_status: {
command: {
preferred: "API_POLLING",
active: "API_POLLING",
state: "DEGRADED",
backlog_depth: 0,
last_success_at: "2026-04-08 08:15:00",
},
shell: {
preferred: "API_POLLING",
active: "API_POLLING",
state: "ONLINE",
backlog_depth: 0,
last_success_at: "2026-04-08 08:15:00",
},
broker: {
connected: false,
state: "OFFLINE",
last_error: "Broker unavailable",
},
},
transport_health: {
status: "DEGRADED",
summary: "API polling er aktiv som fallback",
recommended_action: "review_binding_override",
},
},
],
},
});
await primeSuperuserSession(page);
@@ -144,13 +222,17 @@ test.describe("Edge gateway management smoke", () => {
await expect(page).toHaveURL(/\/superuser\/departments\/1\/gateways$/);
await expect(page.locator("body")).toContainText("Gateway for afdelingen");
await expect(page.getByTestId("gateway-open-full-page")).toBeVisible();
await openRecoverStep(page);
await openAdvancedOperations(page);
await page.getByLabel("Transporttilstand").selectOption("cloud");
await page.getByRole("button", { name: "Anvend cutover" }).click();
await expect(page.getByLabel("Transporttilstand")).toHaveValue("cloud");
await expect(page.locator("body")).toContainText("Cloud fallback");
});
test("@smoke shows API polling as the control channel while discovery still works", async ({ page }) => {
test("@smoke keeps control-channel state visible in the health step while discovery still works", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
@@ -159,8 +241,9 @@ test.describe("Edge gateway management smoke", () => {
await page.goto("/superuser/gateways");
await expect(page.locator("body")).toContainText("API polling");
await page.getByRole("button", { name: /discovery/i }).click();
await expect(page.locator("body")).toContainText("Broker fast path");
await openConfigureStep(page);
await page.getByTestId("gateway-discovery-action").click();
await expect(page.locator("body")).toContainText("Discovery er");
await expect(page.locator("body")).toContainText("shelly-plus-new");
});
@@ -173,9 +256,10 @@ test.describe("Edge gateway management smoke", () => {
await primeSuperuserSession(page);
await page.goto("/superuser/gateways/701");
await openRecoverStep(page);
await page.locator('input[placeholder="F.eks. 1.3.0"]').fill("1.3.0-rollback");
await page.getByRole("button", { name: /opdatering/i }).click();
await page.getByTestId("gateway-update-queue").click();
const latestUpdate = page.getByTestId("gateway-update-item").first();
await expect(latestUpdate).toContainText("1.3.0-rollback");
+15 -19
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
async function primeSuperuserSession(page) {
@@ -15,31 +15,27 @@ test.describe("Edge gateway visuals", () => {
await primeSuperuserSession(page);
});
test("desktop hierarchy snapshot", async ({ page }, testInfo) => {
test("desktop tabbed hierarchy", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
await page.goto("/superuser/gateways");
const workspace = page.locator(".edge-workspace");
await expect(workspace).toContainText("Valgt gateway");
await expect(workspace).toContainText("API polling");
await expect(workspace).toContainText("One-line Raspberry Pi-installation");
await expect(workspace).toContainText("Discovery på det lokale netværk");
await expect(workspace).toContainText("Knyt relæer til lokale Shelly-enheder");
await expect(workspace).toContainText("Styr agent-versioner");
await expect(workspace).toContainText("Break-glass adgang");
await expect(workspace).toContainText("Audit-log");
await expect(workspace).toHaveScreenshot("edge-gateways-desktop.png", {
maxDiffPixels: 250,
});
await expect(page.getByTestId("gateway-step-shell-nav")).toContainText("Kategorier");
await expect(page.getByTestId("gateway-step-assess")).toContainText("Vurder drift");
await expect(page.getByTestId("gateway-step-configure")).toContainText("Konfigurer lokalt");
await expect(page.getByTestId("gateway-step-recover")).toContainText("Gendan og vedligehold");
await expect(page.getByTestId("gateway-detail-header")).toContainText("Situationsbillede");
await expect(page.getByTestId("gateway-fleet-rail")).toContainText("Vælg en gateway");
});
test("mobile hierarchy snapshot", async ({ page }, testInfo) => {
test("mobile tabbed hierarchy", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("mobile"), "Mobile only");
await page.goto("/superuser/gateways");
await expect(page.locator(".edge-workspace")).toContainText("Valgt gateway");
await expect(page.locator(".edge-workspace")).toHaveScreenshot("edge-gateways-mobile.png", {
maxDiffPixels: 250,
});
await expect(page.getByTestId("gateway-mobile-open-rail")).toBeVisible();
await expect(page.getByTestId("gateway-step-shell-nav")).toContainText("Kategorier");
await page.getByTestId("gateway-mobile-open-rail").click();
await expect(page.getByTestId("gateway-fleet-rail")).toContainText("Vælg en gateway");
});
});
+187 -1
View File
@@ -642,6 +642,76 @@ function mergeEdgeGatewayFixtureRecord(baseGateway, overrides = {}) {
};
}
function buildEdgeGatewayRuntimeFixture(gateway) {
const relayHealth = (gateway.bindings || []).map((binding) => {
const fallbackMode = binding.fallback_mode || "PREFER_LOCAL";
const executionPath =
gateway.department_transport_mode === "cloud" || fallbackMode === "CLOUD_ONLY" ? "cloud" : "local";
const reason =
gateway.department_transport_mode === "cloud"
? "department_cutover"
: fallbackMode === "CLOUD_ONLY"
? "binding_cloud_only"
: null;
return {
binding_id: binding.id,
relay_id: binding.relay_id,
fallback_mode: fallbackMode,
execution_path: executionPath,
reason,
recommended_action: reason ? "review_binding_override" : null,
device_freshness_state: "READY",
};
});
const cloudRelays = relayHealth.filter((relay) => relay.execution_path === "cloud");
return {
...gateway,
channel_status: gateway.channel_status || {
command: {
preferred: gateway.metadata?.broker_connected ? "BROKER_FAST_PATH" : "API_POLLING",
active: gateway.metadata?.broker_connected ? "BROKER_FAST_PATH" : "API_POLLING",
state: gateway.status === "OFFLINE" ? "OFFLINE" : gateway.metadata?.broker_connected ? "ONLINE" : "DEGRADED",
backlog_depth: 0,
last_success_at: gateway.last_heartbeat_at,
},
shell: {
preferred: "API_POLLING",
active: "API_POLLING",
state: gateway.status,
backlog_depth: 0,
last_success_at: gateway.last_heartbeat_at,
},
broker: {
connected: Boolean(gateway.metadata?.broker_connected),
state: gateway.metadata?.broker_connected ? "ONLINE" : "OFFLINE",
last_error: gateway.metadata?.broker_connected ? null : "Broker unavailable",
},
},
transport_health: gateway.transport_health || {
status: cloudRelays.length ? "DEGRADED" : gateway.status,
summary: cloudRelays.length
? `${cloudRelays.length} relæ(er) kører via cloud fallback`
: "Broker fast path er aktiv med API polling som fallback",
recommended_action: cloudRelays.length ? "review_binding_override" : null,
},
fallback_summary: gateway.fallback_summary || {
local_relays: relayHealth.filter((relay) => relay.execution_path === "local").length,
cloud_relays: cloudRelays.length,
local_only_relays: relayHealth.filter((relay) => relay.fallback_mode === "LOCAL_ONLY").length,
cloud_only_relays: relayHealth.filter((relay) => relay.fallback_mode === "CLOUD_ONLY").length,
affected_relays: cloudRelays.map((relay) => relay.relay_id),
recommended_action: cloudRelays.length ? "review_binding_override" : null,
},
relay_health: gateway.relay_health || relayHealth,
last_successful_command_at: gateway.last_successful_command_at || gateway.last_heartbeat_at,
last_successful_discovery_at: gateway.last_successful_discovery_at || gateway.last_heartbeat_at,
last_successful_shell_at: gateway.last_successful_shell_at || gateway.last_heartbeat_at,
};
}
function createEdgeGatewayFixture(options = {}) {
const primaryBrokerConnected = options.brokerConnected ?? true;
const gatewayOverridesById = new Map(
@@ -707,6 +777,7 @@ function createEdgeGatewayFixture(options = {}) {
local_ip: "10.1.0.31",
channel: 0,
binding_source: "MANUAL",
fallback_mode: "PREFER_LOCAL",
},
{
id: 2,
@@ -715,6 +786,7 @@ function createEdgeGatewayFixture(options = {}) {
local_ip: "10.1.0.99",
channel: 1,
binding_source: "MANUAL",
fallback_mode: "CLOUD_ONLY",
},
],
recent_updates: [{ id: 11, target_version: "1.2.1", status: "COMPLETED" }],
@@ -757,7 +829,9 @@ function createEdgeGatewayFixture(options = {}) {
recent_commands: [],
audit_logs: [{ id: 502, created_at: "2026-04-07 21:05:00", action: "HEARTBEAT_TIMEOUT", actor_type: "SYSTEM" }],
},
].map((gateway) => mergeEdgeGatewayFixtureRecord(gateway, gatewayOverridesById.get(Number(gateway.id)) || {}));
].map((gateway) => buildEdgeGatewayRuntimeFixture(
mergeEdgeGatewayFixtureRecord(gateway, gatewayOverridesById.get(Number(gateway.id)) || {})
));
return {
pendingDiscoveryByGatewayId: {
@@ -766,6 +840,9 @@ function createEdgeGatewayFixture(options = {}) {
pendingUpdateByGatewayId: {
...(options.pendingUpdateByGatewayId || {}),
},
pendingUninstallByGatewayId: {
...(options.pendingUninstallByGatewayId || {}),
},
shellSessionsById: {},
gateways: baseGateways,
};
@@ -844,6 +921,40 @@ function settleEdgeGatewayWork(edgeGatewayFixture, gatewayId) {
}
}
const pendingUninstall = edgeGatewayFixture.pendingUninstallByGatewayId[gatewayId];
if (pendingUninstall) {
pendingUninstall.fetchCount = (pendingUninstall.fetchCount || 0) + 1;
if (pendingUninstall.fetchCount >= 2) {
gateway.status = "OFFLINE";
gateway.metadata = {
...(gateway.metadata || {}),
pending_uninstall: null,
last_uninstall: {
ok: true,
completed_at: "2026-04-09 12:47:00",
error: null,
},
broker_connected: false,
};
gateway.recent_commands = (gateway.recent_commands || []).map((job) =>
Number(job.id) === Number(pendingUninstall.jobId)
? {
...job,
status: "COMPLETED",
response: {
ok: true,
payload: {
uninstall_scheduled: true,
},
},
}
: job
);
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
delete edgeGatewayFixture.pendingUninstallByGatewayId[gatewayId];
}
}
return gateway;
}
@@ -875,11 +986,21 @@ function createShellSessionFixture(edgeGatewayFixture, gatewayId, session, shell
closed_at: "2026-04-09 12:45:30",
metadata: {
closed_reason: "open_failed",
reconnect_state: "FAILED",
transport_path: "API_POLLING",
},
};
} else {
pushEvent("OPENED", {});
pushEvent("OUTPUT", { data: shellConfig.prompt });
state.session = {
...state.session,
metadata: {
...(state.session.metadata || {}),
reconnect_state: "CONNECTED",
transport_path: "API_POLLING",
},
};
}
edgeGatewayFixture.shellSessionsById[session.id] = state;
@@ -2178,7 +2299,9 @@ async function handleEdgeGatewayRoute({
gateway.bindings = (body.bindings || []).map((binding, index) => ({
id: index + 1,
...binding,
fallback_mode: binding.fallback_mode || "PREFER_LOCAL",
}));
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
}
await route.fulfill(json({ data: gateway?.bindings || [] }));
return true;
@@ -2210,6 +2333,47 @@ async function handleEdgeGatewayRoute({
return true;
}
if (/\/edge-gateways\/\d+\/uninstall$/.test(pathname) && method === "POST") {
const gatewayId = Number(pathname.split("/")[2]);
const gateway = edgeGatewayFixture.gateways.find((item) => item.id === gatewayId);
const job = {
id: Date.now(),
command_type: "UNINSTALL_AGENT",
status: "PENDING",
request: {
serviceName: "truckwash-edge-agent.service",
},
response: {},
};
if (gateway) {
gateway.metadata = {
...(gateway.metadata || {}),
pending_uninstall: {
requested_at: "2026-04-09 12:46:00",
},
};
gateway.recent_commands = [job, ...(gateway.recent_commands || [])];
edgeGatewayFixture.pendingUninstallByGatewayId[gatewayId] = {
jobId: job.id,
fetchCount: 0,
};
}
await route.fulfill(
json(
{
data: {
gateway,
job,
},
},
202
)
);
return true;
}
if (/\/edge-gateways\/\d+\/shell-sessions$/.test(pathname) && method === "POST") {
const gatewayId = Number(pathname.split("/")[2]);
const gateway = edgeGatewayFixture.gateways.find((item) => item.id === gatewayId);
@@ -2221,6 +2385,8 @@ async function handleEdgeGatewayRoute({
expires_at: "2026-04-09 12:45:00",
metadata: {
transport: "API_POLLING",
transport_path: "API_POLLING",
reconnect_state: "PENDING",
},
};
if (gateway) {
@@ -2346,6 +2512,26 @@ async function handleEdgeGatewayRoute({
return true;
}
if (/\/edge-gateways\/\d+$/.test(pathname) && method === "DELETE") {
const gatewayId = Number(pathname.split("/").pop());
const gateway = edgeGatewayFixture.gateways.find((item) => item.id === gatewayId);
edgeGatewayFixture.gateways = edgeGatewayFixture.gateways.filter((item) => item.id !== gatewayId);
delete edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId];
delete edgeGatewayFixture.pendingUpdateByGatewayId[gatewayId];
delete edgeGatewayFixture.pendingUninstallByGatewayId[gatewayId];
await route.fulfill(
json({
data: {
deleted: true,
gateway_id: gatewayId,
department_id: gateway?.department_id ?? null,
},
})
);
return true;
}
if (/\/departments\/\d+\/gateway-cutover$/.test(pathname) && method === "POST") {
const departmentId = Number(pathname.split("/")[2]);
const body = request.postDataJSON?.() || {};
+9
View File
@@ -0,0 +1,9 @@
import type { TestInfo } from "@playwright/test";
export function isCompactProject(testInfo: TestInfo) {
return /(mobile|tablet)/i.test(testInfo.project.name);
}
export function isDesktopProject(testInfo: TestInfo) {
return /desktop/i.test(testInfo.project.name);
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
buildRelayHealthRows,
mergeWorkspaceDraftState,
resolveIncidentPrimaryAction,
resolveNextWorkflowStep,
} from "@/components/displays/edgeGateway/edgeGatewayWorkspace.helpers.js";
describe("edge gateway workflow helpers", () => {
it("derives the next workflow step from selection state", () => {
expect(resolveNextWorkflowStep("select", false)).toBe("select");
expect(resolveNextWorkflowStep("select", true)).toBe("assess");
expect(resolveNextWorkflowStep("configure", true)).toBe("configure");
expect(resolveNextWorkflowStep("recover", true)).toBe("recover");
});
it("maps backend recommended actions into guided workflow CTAs", () => {
expect(
resolveIncidentPrimaryAction({
transport_health: { recommended_action: "retry_discovery" },
fallback_summary: {},
})
).toMatchObject({
label: "Kør discovery nu",
targetStep: "configure",
kind: "trigger_discovery",
});
expect(
resolveIncidentPrimaryAction({
status: "OFFLINE",
transport_health: {},
fallback_summary: {},
})
).toMatchObject({
label: "Åbn vedligeholdelse",
targetStep: "recover",
kind: "switch_step",
});
});
it("formats relay health rows with fallback and execution labels", () => {
const rows = buildRelayHealthRows(
{
relay_health: [
{
binding_id: 12,
relay_id: "M-7",
fallback_mode: "CLOUD_ONLY",
execution_path: "cloud",
device_freshness_state: "STALE",
reason: "device_stale",
},
],
},
[]
);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
fallbackModeLabel: "Kun cloud",
executionPathLabel: "Cloud fallback",
freshnessLabel: "Forældet",
reasonLabel: "Shelly-device er for gammelt i discovery",
});
});
it("preserves dirty editor state while merging fresh server data", () => {
const merged = mergeWorkspaceDraftState({
gateway: {
bindings: [{ relay_id: "M-1", device_id: "server-device", channel: 1 }],
target_version: "1.2.3",
department_transport_mode: "cloud",
},
currentState: {
editableBindings: [{ relay_id: "M-9", device_id: "draft-device", channel: 0 }],
targetVersion: "9.9.9",
transportMode: "gateway",
},
dirtyState: {
bindings: true,
targetVersion: true,
transportMode: false,
},
});
expect(merged.editableBindings).toEqual([{ relay_id: "M-9", device_id: "draft-device", channel: 0 }]);
expect(merged.targetVersion).toBe("9.9.9");
expect(merged.transportMode).toBe("cloud");
});
});
+44 -81
View File
@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
@@ -6,6 +6,14 @@ const workspaceSource = readFileSync(
join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue"),
"utf8"
);
const advancedOperationsSource = readFileSync(
join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayAdvancedOperations.vue"),
"utf8"
);
const contextPanelSource = readFileSync(
join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayContextPanel.vue"),
"utf8"
);
const routerSource = readFileSync(join(process.cwd(), "src/router.js"), "utf8");
const navSource = readFileSync(
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue"),
@@ -21,78 +29,33 @@ const departmentGatewaysViewSource = readFileSync(
);
describe("edge gateway workspace contract", () => {
it("renders the redesigned workflow sections and delegates root shell UI to the terminal component", () => {
expect(workspaceSource).toContain("Valgt gateway");
expect(workspaceSource).toContain("Overblik");
expect(workspaceSource).toContain("Installation");
expect(workspaceSource).toContain("Shelly-enheder");
expect(workspaceSource).toContain("Bindinger");
expect(workspaceSource).toContain("Opdateringer");
expect(workspaceSource).toContain("Historik");
expect(workspaceSource).toContain('import EdgeGatewayTerminal from "./EdgeGatewayTerminal.vue";');
expect(workspaceSource).toContain("<EdgeGatewayTerminal");
expect(workspaceSource).toContain('@session-approved="handleShellSessionApproved"');
it("renders the guided workflow container through focused child components", () => {
expect(workspaceSource).toContain('import { useEdgeGatewayWorkspace } from "@/composables/useEdgeGatewayWorkspace.js";');
expect(workspaceSource).toContain('import EdgeGatewayFleetRail from "./EdgeGatewayFleetRail.vue";');
expect(workspaceSource).toContain('import EdgeGatewayContextPanel from "./EdgeGatewayContextPanel.vue";');
expect(workspaceSource).toContain('import EdgeGatewayHealthSummary from "./EdgeGatewayHealthSummary.vue";');
expect(workspaceSource).toContain('import EdgeGatewayConfigurePanel from "./EdgeGatewayConfigurePanel.vue";');
expect(workspaceSource).toContain('import EdgeGatewayMaintenancePanel from "./EdgeGatewayMaintenancePanel.vue";');
expect(workspaceSource).toContain('import EdgeGatewayAdvancedOperations from "./EdgeGatewayAdvancedOperations.vue";');
expect(workspaceSource).not.toContain('import EdgeGatewayTerminal from "./EdgeGatewayTerminal.vue";');
expect(workspaceSource).toContain('data-testid="gateway-step-shell-nav"');
expect(workspaceSource).toContain('gateway-step-${step.key}');
expect(workspaceSource).toContain('data-testid="gateway-step-select-panel"');
expect(workspaceSource).toContain('data-testid="gateway-unavailable-state"');
expect(contextPanelSource).toContain('data-testid="gateway-context-panel"');
});
it("renders queued discovery/update flows and the API polling control channel", () => {
expect(workspaceSource).toContain("Generér installer");
expect(workspaceSource).toContain('aria-label="Installer afdeling"');
expect(workspaceSource).toContain("Kør discovery");
expect(workspaceSource).toContain("Styringskanal");
expect(workspaceSource).toContain("Latens");
expect(workspaceSource).toContain("CPU");
expect(workspaceSource).toContain("RAM");
expect(workspaceSource).toContain("Disk");
expect(workspaceSource).toContain("getControlChannelMeta");
expect(workspaceSource).toContain("buildGatewayMetricView");
expect(workspaceSource).toContain("system_metrics");
expect(workspaceSource).toContain("startGatewayRefreshLoop");
expect(workspaceSource).toContain("stopGatewayRefreshLoop");
expect(workspaceSource).toContain("BACKGROUND_GATEWAY_REFRESH_INTERVAL_MS = 5_000");
expect(workspaceSource).toContain("startBackgroundGatewayRefreshLoop");
expect(workspaceSource).toContain("refreshWorkspaceDataInBackground");
expect(workspaceSource).toContain("Discovery er køet");
expect(workspaceSource).toContain("Opdatering er køet.");
expect(workspaceSource).toContain("resolveEdgeGatewayErrorMessage");
expect(workspaceSource).toContain("normalizeDisplayText");
expect(workspaceSource).toContain("decodeMojibakeText");
expect(workspaceSource).toContain(
"const displayErrorMessage = computed(() => decodeMojibakeText(normalizeDisplayText(errorMessage.value)));"
);
expect(workspaceSource).toContain("{{ displayErrorMessage }}");
expect(workspaceSource).toContain("Gateway-agenten er offline og kan ikke hente nye kommandoer lige nu.");
expect(workspaceSource).toContain("Gatewayen henter kommandoer og shell-events via API'et.");
expect(workspaceSource).toContain("Gem bindinger");
expect(workspaceSource).toContain("Planlæg opdatering");
expect(workspaceSource).toContain("Verificerer genstart");
expect(workspaceSource).toContain("Rullet tilbage");
expect(workspaceSource).toContain("Transporttilstand");
expect(workspaceSource).toContain("Break-glass adgang");
expect(workspaceSource).toContain("Gateway transport");
expect(workspaceSource).toContain("Afdelingens cutover");
expect(workspaceSource).toContain('data-testid="gateway-detail-header"');
expect(workspaceSource).toContain('data-testid="gateway-fleet-rail"');
expect(workspaceSource).toContain('data-testid="gateway-update-timeline"');
expect(workspaceSource).toContain('data-testid="gateway-rotation-confirmation"');
expect(workspaceSource).toContain('data-testid="gateway-rotation-result"');
expect(workspaceSource).toContain('data-testid="gateway-open-full-page"');
expect(workspaceSource).toContain("align-self: start;");
expect(workspaceSource).toContain(".edge-form-grid--installer");
it("keeps route-aware props and page-level selection wiring intact", () => {
expect(workspaceSource).toContain("selectedGatewayId: {");
expect(workspaceSource).toContain('defineEmits(["open-gateway-page", "select-gateway"])');
expect(edgeGatewaysViewSource).toContain('route.name === "edgegatewaydetail"');
expect(edgeGatewaysViewSource).toContain(':selected-gateway-id="selectedGatewayId"');
expect(edgeGatewaysViewSource).toContain('@select-gateway="handleGatewaySelection"');
expect(departmentGatewaysViewSource).toContain('@open-gateway-page="openGatewayPage"');
expect(departmentGatewaysViewSource).toContain('name: "edgegatewaydetail"');
});
it("builds richer binding device options and keeps stale selections editable", () => {
expect(workspaceSource).toContain("const inventoryDeviceOptions = computed(() =>");
expect(workspaceSource).toContain("const buildInventoryOptionLabel =");
expect(workspaceSource).toContain("const getBindingInventoryDevice = (binding) =>");
expect(workspaceSource).toContain("const getBindingDeviceOptions = (binding) =>");
expect(workspaceSource).toContain("Mangler i discovery");
expect(workspaceSource).toContain("{{ bindingHelperText }}");
expect(workspaceSource).toContain("{{ emptyBindingStateDescription }}");
expect(workspaceSource).toContain('binding.local_ip = device.local_ip ?? "";');
expect(workspaceSource).toContain("binding.channel = channelOptions[0];");
});
it("registers fleet, canonical detail, and department gateway routes in the router and navigation", () => {
it("keeps canonical fleet and department routes registered", () => {
expect(routerSource).toContain("path: '/superuser/gateways'");
expect(routerSource).toContain("name: 'edgegatewaydetail'");
expect(routerSource).toContain("path: '/superuser/gateways/:id'");
@@ -100,17 +63,17 @@ describe("edge gateway workspace contract", () => {
expect(navSource).toContain("'/superuser/departments/' + departmentId.value + '/gateways'");
});
it("keeps route selection in the page layer and lets the department page deep-link into the canonical detail page", () => {
expect(workspaceSource).toContain("selectedGatewayId: {");
expect(workspaceSource).toContain('defineEmits(["open-gateway-page", "select-gateway"])');
expect(workspaceSource).toContain(
"const routeSelectedGatewayId = computed(() => normalizeGatewayId(props.selectedGatewayId));"
);
expect(workspaceSource).toContain('data-testid="gateway-unavailable-state"');
expect(edgeGatewaysViewSource).toContain('route.name === "edgegatewaydetail"');
expect(edgeGatewaysViewSource).toContain(':selected-gateway-id="selectedGatewayId"');
expect(edgeGatewaysViewSource).toContain('@select-gateway="handleGatewaySelection"');
expect(departmentGatewaysViewSource).toContain('@open-gateway-page="openGatewayPage"');
expect(departmentGatewaysViewSource).toContain('name: "edgegatewaydetail"');
it("delegates risky operations and the root shell surface to the advanced operations panel", () => {
expect(workspaceSource).toContain('<EdgeGatewayAdvancedOperations');
expect(advancedOperationsSource).toContain('import EdgeGatewayTerminal from "./EdgeGatewayTerminal.vue";');
expect(advancedOperationsSource).toContain('data-testid="gateway-advanced-operations"');
expect(advancedOperationsSource).toContain('data-testid="gateway-step-shell"');
expect(advancedOperationsSource).toContain('data-testid="gateway-relay-health-table"');
expect(advancedOperationsSource).toContain('data-testid="gateway-rotate-credentials"');
expect(advancedOperationsSource).toContain('data-testid="gateway-cutover-apply"');
expect(advancedOperationsSource).toContain('data-testid="gateway-uninstall"');
expect(advancedOperationsSource).toContain('data-testid="gateway-delete"');
expect(advancedOperationsSource).toContain('data-testid="gateway-uninstall-confirmation"');
expect(advancedOperationsSource).toContain('data-testid="gateway-delete-confirmation"');
});
});
@@ -233,6 +233,64 @@ describe("useEdgeGatewayTerminal", () => {
unmount();
});
it("clears lingering document text selection when the shell opens", async () => {
const fakeTerminal = createFakeTerminal();
const fakeFitAddon = { fit: vi.fn() };
const shellApi = createShellApi("success");
const removeAllRanges = vi.fn();
const originalGetSelection = window.getSelection;
window.getSelection = vi.fn(() => ({
removeAllRanges,
}));
const { composable, unmount } = mountComposable(() =>
useEdgeGatewayTerminal({
pollSessionEvents: shellApi.pollSessionEvents,
sendSessionInput: shellApi.sendSessionInput,
sendSessionResize: shellApi.sendSessionResize,
closeSession: shellApi.closeSession,
createTerminal: () => fakeTerminal,
createFitAddon: () => fakeFitAddon,
resizeObserverFactory: () => ({
observe: vi.fn(),
disconnect: vi.fn(),
}),
})
);
composable.setTerminalHost(document.createElement("div"));
await composable.approveSession({
gatewayId: 701,
reason: "Investigate relay drift",
createSession: async () => ({
data: {
data: {
session: {
id: 44,
gateway_id: 701,
reason: "Investigate relay drift",
expires_at: "2026-04-09 10:15:00",
metadata: {
transport: "API_POLLING",
},
},
transport: "API_POLLING",
},
},
}),
});
await waitForCondition(() => composable.connectionState.value === EDGE_GATEWAY_TERMINAL_STATES.connected);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(removeAllRanges).toHaveBeenCalled();
unmount();
window.getSelection = originalGetSelection;
});
it("surfaces polling failures before the shell opens", async () => {
const fakeTerminal = createFakeTerminal();
const shellApi = createShellApi("fail-before-open");