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:
+27
-10
@@ -98,7 +98,27 @@ jobs:
|
|||||||
e2e-full:
|
e2e-full:
|
||||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
||||||
needs: build-and-unit
|
needs: build-and-unit
|
||||||
|
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -113,22 +133,19 @@ jobs:
|
|||||||
run: npm ci --legacy-peer-deps
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps ${{ matrix.browser_install }}
|
||||||
|
|
||||||
- name: Run full Playwright suite
|
- name: Run full Playwright slice
|
||||||
run: npm run test:e2e:ci
|
run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
|
||||||
env:
|
|
||||||
PLAYWRIGHT_PARALLEL_WORKERS_CHROMIUM: 2
|
|
||||||
PLAYWRIGHT_PARALLEL_WORKERS_FIREFOX: 1
|
|
||||||
PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT: 2
|
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report-full
|
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
|
||||||
path: |
|
path: |
|
||||||
output/playwright/ci-parallel-report
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
|
||||||
output/playwright/ci-parallel-*
|
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
|
||||||
|
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ npm run test:e2e:batched:chromium -- --grep @smoke
|
|||||||
```
|
```
|
||||||
|
|
||||||
Artifacts and summaries:
|
Artifacts and summaries:
|
||||||
|
|
||||||
- `output/playwright/batched-chromium/last-run.json`
|
- `output/playwright/batched-chromium/last-run.json`
|
||||||
- `output/playwright/batched-chromium/failed-shards.json`
|
- `output/playwright/batched-chromium/failed-shards.json`
|
||||||
- `output/playwright/batched-chromium/report-index.html`
|
- `output/playwright/batched-chromium/report-index.html`
|
||||||
@@ -65,12 +66,22 @@ Artifacts and summaries:
|
|||||||
|
|
||||||
## Playwright Full E2E
|
## 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
|
```sh
|
||||||
npm run test:e2e:ci
|
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:
|
Default worker allocation:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -91,10 +102,12 @@ PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=2
|
|||||||
The runner fails fast if the combined worker count exceeds 5.
|
The runner fails fast if the combined worker count exceeds 5.
|
||||||
|
|
||||||
Artifacts and summaries:
|
Artifacts and summaries:
|
||||||
|
|
||||||
- `output/playwright/ci-parallel-report/index.html`
|
- `output/playwright/ci-parallel-report/index.html`
|
||||||
- `output/playwright/ci-parallel-chromium/report/index.html`
|
- `output/playwright/ci-parallel-chromium/report/index.html`
|
||||||
- `output/playwright/ci-parallel-firefox/report/index.html`
|
- `output/playwright/ci-parallel-firefox/report/index.html`
|
||||||
- `output/playwright/ci-parallel-webkit/report/index.html`
|
- `output/playwright/ci-parallel-webkit/report/index.html`
|
||||||
|
- `output/playwright/test-lists/<role>-<project>.txt`
|
||||||
|
|
||||||
## Bubblewrap (TWA) Build and Install
|
## Bubblewrap (TWA) Build and Install
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
|
"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: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:ci:serial": "playwright test --reporter=line,html",
|
||||||
"test:e2e:batched:chromium": "node scripts/run-playwright-batched-chromium.mjs",
|
"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",
|
"test:e2e:batched:chromium:rerun-failed": "node scripts/run-playwright-batched-chromium.mjs --rerun-failed",
|
||||||
|
|||||||
+30
-40
@@ -14,14 +14,25 @@ const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? co
|
|||||||
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
|
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
|
||||||
const reporter =
|
const reporter =
|
||||||
reporterMode === "line-html"
|
reporterMode === "line-html"
|
||||||
? [
|
? [["line"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]]
|
||||||
["line"],
|
: [["list"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]];
|
||||||
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
|
|
||||||
]
|
function buildProject(name: string, browserName: "chromium" | "firefox" | "webkit", deviceName: keyof typeof devices) {
|
||||||
: [
|
const { defaultBrowserType, ...device } = devices[deviceName];
|
||||||
["list"],
|
const use = {
|
||||||
["html", { open: "never", outputFolder: htmlReportOutputFolder }],
|
browserName,
|
||||||
];
|
...device,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (browserName === "firefox") {
|
||||||
|
delete use.isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
use,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
@@ -43,38 +54,17 @@ export default defineConfig({
|
|||||||
baseURL,
|
baseURL,
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
video: "retain-on-failure"
|
video: "retain-on-failure",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
buildProject("chromium-desktop", "chromium", "Desktop Chrome"),
|
||||||
name: "chromium-desktop",
|
buildProject("chromium-tablet", "chromium", "iPad Mini"),
|
||||||
use: {
|
buildProject("chromium-mobile", "chromium", "Pixel 5"),
|
||||||
...devices["Desktop Chrome"]
|
buildProject("firefox-desktop", "firefox", "Desktop Firefox"),
|
||||||
}
|
buildProject("firefox-tablet", "firefox", "iPad Mini"),
|
||||||
},
|
buildProject("firefox-mobile", "firefox", "Pixel 5"),
|
||||||
{
|
buildProject("webkit-desktop", "webkit", "Desktop Safari"),
|
||||||
name: "chromium-mobile",
|
buildProject("webkit-tablet", "webkit", "iPad Mini"),
|
||||||
use: {
|
buildProject("webkit-mobile", "webkit", "iPhone 12"),
|
||||||
...devices["Pixel 5"]
|
],
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "firefox-desktop",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Firefox"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "webkit-desktop",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Safari"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "webkit-mobile",
|
|
||||||
use: {
|
|
||||||
...devices["iPhone 12"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|||||||
+70
-14
@@ -10,6 +10,8 @@ const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `po
|
|||||||
.trim()
|
.trim()
|
||||||
.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||||
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
|
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) {
|
async function getListeningProcessOnWindows(port) {
|
||||||
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
|
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}.`);
|
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() {
|
export default async function globalSetup() {
|
||||||
if (process.env.PLAYWRIGHT_BASE_URL) {
|
if (process.env.PLAYWRIGHT_BASE_URL) {
|
||||||
return;
|
return;
|
||||||
@@ -121,20 +180,16 @@ export default async function globalSetup() {
|
|||||||
|
|
||||||
const serverProcess =
|
const serverProcess =
|
||||||
process.platform === "win32"
|
process.platform === "win32"
|
||||||
? spawn(
|
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`], {
|
||||||
"cmd.exe",
|
cwd: process.cwd(),
|
||||||
["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`],
|
detached: true,
|
||||||
{
|
env: {
|
||||||
cwd: process.cwd(),
|
...process.env,
|
||||||
detached: true,
|
PLAYWRIGHT: "1",
|
||||||
env: {
|
},
|
||||||
...process.env,
|
stdio: "ignore",
|
||||||
PLAYWRIGHT: "1",
|
windowsHide: true,
|
||||||
},
|
})
|
||||||
stdio: "ignore",
|
|
||||||
windowsHide: true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
|
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
detached: true,
|
detached: true,
|
||||||
@@ -151,6 +206,7 @@ export default async function globalSetup() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await waitForServerReady(baseURL);
|
await waitForServerReady(baseURL);
|
||||||
|
await warmUpDevServer(baseURL);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await killProcessTree(serverProcess.pid);
|
await killProcessTree(serverProcess.pid);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ let isShuttingDown = false;
|
|||||||
const groups = [
|
const groups = [
|
||||||
{
|
{
|
||||||
name: "chromium",
|
name: "chromium",
|
||||||
projects: ["chromium-desktop", "chromium-mobile"],
|
projects: ["chromium-desktop", "chromium-tablet", "chromium-mobile"],
|
||||||
defaultWorkers: 2,
|
defaultWorkers: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "firefox",
|
name: "firefox",
|
||||||
projects: ["firefox-desktop"],
|
projects: ["firefox-desktop", "firefox-tablet", "firefox-mobile"],
|
||||||
defaultWorkers: 1,
|
defaultWorkers: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "webkit",
|
name: "webkit",
|
||||||
projects: ["webkit-desktop", "webkit-mobile"],
|
projects: ["webkit-desktop", "webkit-tablet", "webkit-mobile"],
|
||||||
defaultWorkers: 2,
|
defaultWorkers: 2,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -130,11 +130,7 @@ function prefixStream(stream, prefix) {
|
|||||||
function spawnGroup(group, index) {
|
function spawnGroup(group, index) {
|
||||||
const devPort = basePort + index;
|
const devPort = basePort + index;
|
||||||
const artifactNamespace = getArtifactNamespace(group);
|
const artifactNamespace = getArtifactNamespace(group);
|
||||||
const args = [
|
const args = ["test", ...forwardedArgs, ...group.projects.flatMap((project) => ["--project", project])];
|
||||||
"test",
|
|
||||||
...forwardedArgs,
|
|
||||||
...group.projects.flatMap((project) => ["--project", project]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
|
const child = spawn(process.execPath, [playwrightCliPath, ...args], {
|
||||||
cwd: workingDirectory,
|
cwd: workingDirectory,
|
||||||
@@ -257,7 +253,11 @@ async function main() {
|
|||||||
const totalWorkers = validateWorkerCap(configuredGroups);
|
const totalWorkers = validateWorkerCap(configuredGroups);
|
||||||
|
|
||||||
console.log(
|
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))));
|
await Promise.all(configuredGroups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group))));
|
||||||
@@ -274,7 +274,10 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
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")
|
||||||
|
)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, så 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, så agenten kan stoppe sig selv på 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 nå 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 på en ny Pi på 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 { computed, nextTick, ref, watch } from "vue";
|
||||||
import { Terminal } from "xterm";
|
import { Terminal } from "xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
@@ -134,6 +134,42 @@ const formatFutureTimestamp = (value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sessionMeta = computed(() => session.value ?? props.latestSession ?? null);
|
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(() => {
|
const sessionExpiryText = computed(() => {
|
||||||
if (!sessionMeta.value?.expires_at) {
|
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)})`;
|
return `Session udløber ${formatFutureTimestamp(sessionMeta.value.expires_at)} (${formatAbsoluteTimestamp(sessionMeta.value.expires_at)})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusLabel = computed(() => {
|
const terminalStatus = computed(() => {
|
||||||
switch (connectionState.value) {
|
if (
|
||||||
case EDGE_GATEWAY_TERMINAL_STATES.approving:
|
connectionState.value === EDGE_GATEWAY_TERMINAL_STATES.connected &&
|
||||||
return "Godkender";
|
(sessionMetadata.value.degraded || sessionMetadata.value.reconnect_state === "FAILED")
|
||||||
case EDGE_GATEWAY_TERMINAL_STATES.connecting:
|
) {
|
||||||
return "Forbinder";
|
return {
|
||||||
case EDGE_GATEWAY_TERMINAL_STATES.connected:
|
label: "Degraderet",
|
||||||
return "Live";
|
tone: "warning",
|
||||||
case EDGE_GATEWAY_TERMINAL_STATES.closed:
|
};
|
||||||
return "Lukket";
|
|
||||||
case EDGE_GATEWAY_TERMINAL_STATES.error:
|
|
||||||
return "Fejl";
|
|
||||||
default:
|
|
||||||
return "Klar";
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const statusTone = computed(() => {
|
|
||||||
switch (connectionState.value) {
|
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:
|
case EDGE_GATEWAY_TERMINAL_STATES.approving:
|
||||||
|
return { label: "Afventer godkendelse", tone: "warning" };
|
||||||
case EDGE_GATEWAY_TERMINAL_STATES.connecting:
|
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:
|
default:
|
||||||
return "neutral";
|
return { label: "Afventer godkendelse", tone: "neutral" };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,7 +294,7 @@ watch(terminalHost, (element) => {
|
|||||||
bindinger eller planlagte opdateringer.
|
bindinger eller planlagte opdateringer.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" class="button is-warning" data-testid="gateway-shell-open" @click="openPanel">
|
<button type="button" class="button is-warning" data-testid="gateway-shell-open" @click="openPanel">
|
||||||
Åbn root shell-panel
|
Åbn root shell
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -282,12 +314,14 @@ watch(terminalHost, (element) => {
|
|||||||
<div class="edge-shell-card__meta">
|
<div class="edge-shell-card__meta">
|
||||||
<div class="edge-shell-card__status-copy">
|
<div class="edge-shell-card__status-copy">
|
||||||
<div class="edge-shell-card__status-row">
|
<div class="edge-shell-card__status-row">
|
||||||
<span class="edge-shell-card__badge" :data-tone="statusTone">
|
<span class="edge-shell-card__badge" :data-tone="terminalStatus.tone">
|
||||||
{{ statusLabel }}
|
{{ terminalStatus.label }}
|
||||||
</span>
|
</span>
|
||||||
<span class="edge-shell-card__expiry">{{ sessionExpiryText }}</span>
|
<span class="edge-shell-card__expiry">{{ sessionExpiryText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="edge-shell-card__helper">{{ statusDetail }}</p>
|
<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>
|
||||||
|
|
||||||
<div class="edge-shell-card__actions">
|
<div class="edge-shell-card__actions">
|
||||||
@@ -308,7 +342,7 @@ watch(terminalHost, (element) => {
|
|||||||
data-testid="gateway-shell-terminate"
|
data-testid="gateway-shell-terminate"
|
||||||
@click="terminateSession"
|
@click="terminateSession"
|
||||||
>
|
>
|
||||||
Terminate session
|
Afslut session
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -317,7 +351,7 @@ watch(terminalHost, (element) => {
|
|||||||
data-testid="gateway-shell-copy"
|
data-testid="gateway-shell-copy"
|
||||||
@click="copyOutput"
|
@click="copyOutput"
|
||||||
>
|
>
|
||||||
Copy output
|
Kopiér output
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -326,7 +360,7 @@ watch(terminalHost, (element) => {
|
|||||||
data-testid="gateway-shell-clear"
|
data-testid="gateway-shell-clear"
|
||||||
@click="clearOutput"
|
@click="clearOutput"
|
||||||
>
|
>
|
||||||
Clear view
|
Ryd visning
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
||||||
|
}
|
||||||
@@ -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({
|
export function useEdgeGatewayTerminal({
|
||||||
pollSessionEvents,
|
pollSessionEvents,
|
||||||
sendSessionInput,
|
sendSessionInput,
|
||||||
@@ -272,7 +288,11 @@ export function useEdgeGatewayTerminal({
|
|||||||
statusDetail.value = "Live shell er forbundet og klar til input.";
|
statusDetail.value = "Live shell er forbundet og klar til input.";
|
||||||
connectionError.value = "";
|
connectionError.value = "";
|
||||||
appendNotice("[Live shell forbundet]");
|
appendNotice("[Live shell forbundet]");
|
||||||
|
clearDocumentSelection();
|
||||||
terminal?.focus?.();
|
terminal?.focus?.();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
clearDocumentSelection();
|
||||||
|
}, 0);
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,12 @@ export const saveEdgeGatewayBindings = async (gatewayId, bindings) =>
|
|||||||
export const createEdgeGatewayUpdateJob = async (gatewayId, payload) =>
|
export const createEdgeGatewayUpdateJob = async (gatewayId, payload) =>
|
||||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/update-jobs`, "POST", 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) =>
|
export const createEdgeGatewayShellSession = async (gatewayId, payload) =>
|
||||||
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/shell-sessions`, "POST", payload);
|
authenticatedRequest(`/edge-gateways/${encodeURIComponent(gatewayId)}/shell-sessions`, "POST", payload);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import PageTitle from "@/components/global/PageTitle.vue";
|
import PageTitle from "@/components/global/PageTitle.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import PageTitle from "@/components/global/PageTitle.vue";
|
import PageTitle from "@/components/global/PageTitle.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
@@ -31,7 +31,7 @@ const openGatewayPage = async (gatewayId) => {
|
|||||||
<template #title>
|
<template #title>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
title="Gateway for afdelingen"
|
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>
|
</template>
|
||||||
<EdgeGatewayWorkspace :department-id="departmentId" @open-gateway-page="openGatewayPage" />
|
<EdgeGatewayWorkspace :department-id="departmentId" @open-gateway-page="openGatewayPage" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||||
|
import { isCompactProject } from "./support/projects";
|
||||||
|
|
||||||
const adminPermissions = [
|
const adminPermissions = [
|
||||||
"admin",
|
"admin",
|
||||||
@@ -179,7 +180,7 @@ async function mockDailyReportDependencies(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expandDepartmentFiltersOnMobile(page, testInfo) {
|
async function expandDepartmentFiltersOnMobile(page, testInfo) {
|
||||||
if (!testInfo.project.use.isMobile) {
|
if (!isCompactProject(testInfo)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||||
|
import { isCompactProject, isDesktopProject } from "./support/projects";
|
||||||
|
|
||||||
const adminPermissions = [
|
const adminPermissions = [
|
||||||
"admin",
|
"admin",
|
||||||
@@ -96,7 +97,7 @@ async function mockAdminDepartmentDependencies(page, options: { departmentDelayM
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expandDepartmentFiltersOnMobile(page, testInfo) {
|
async function expandDepartmentFiltersOnMobile(page, testInfo) {
|
||||||
if (!testInfo.project.use.isMobile) {
|
if (!isCompactProject(testInfo)) {
|
||||||
return;
|
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: "Hidden South" })).toHaveCount(0);
|
||||||
await expect(departmentControls.getByRole("button", { name: "Numeric Hidden" })).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: "Visible North" })).toHaveCount(1);
|
||||||
await expect(page.locator("option", { hasText: "Legacy East" })).toHaveCount(1);
|
await expect(page.locator("option", { hasText: "Legacy East" })).toHaveCount(1);
|
||||||
await expect(page.locator("option", { hasText: "Hidden South" })).toHaveCount(0);
|
await expect(page.locator("option", { hasText: "Hidden South" })).toHaveCount(0);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||||
|
import { isCompactProject } from "./support/projects";
|
||||||
|
|
||||||
const adminPermissions = [
|
const adminPermissions = [
|
||||||
"admin",
|
"admin",
|
||||||
@@ -112,7 +113,7 @@ test.describe("Admin overview mobile", () => {
|
|||||||
test("keeps modules first, collapses reports by default, and preserves mobile report actions", async ({
|
test("keeps modules first, collapses reports by default, and preserves mobile report actions", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
test.skip(!testInfo.project.use.isMobile, "Mobile-only overview regression");
|
test.skip(!isCompactProject(testInfo), "Compact-device overview regression");
|
||||||
|
|
||||||
await mockOverviewPageDependencies(page);
|
await mockOverviewPageDependencies(page);
|
||||||
await page.goto("/admin/1");
|
await page.goto("/admin/1");
|
||||||
|
|||||||
@@ -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 { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||||
|
import { isCompactProject } from "./support/projects";
|
||||||
|
|
||||||
const adminPermissions = [
|
const adminPermissions = [
|
||||||
"admin",
|
"admin",
|
||||||
@@ -317,7 +318,7 @@ async function mockOverviewDependencies(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expandOverviewReportsOnMobile(page, testInfo) {
|
async function expandOverviewReportsOnMobile(page, testInfo) {
|
||||||
if (!testInfo.project.use.isMobile) {
|
if (!isCompactProject(testInfo)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +330,18 @@ async function expandOverviewReportsOnMobile(page, testInfo) {
|
|||||||
await expect(page.getByTestId("daily-report-page")).toBeVisible();
|
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.describe("Admin overview night washes", () => {
|
||||||
test("renders deduped Døgnvask totals across cards, list, and chart with stable range updates", async ({
|
test("renders deduped Døgnvask totals across cards, list, and chart with stable range updates", async ({
|
||||||
page,
|
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")).toBeVisible();
|
||||||
await expect(page.getByTestId("outside-hours-chart-warning")).toContainText("Missing Hours");
|
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 setOverviewEndDate(page, "2026-03-25");
|
||||||
await endDateInput.fill("2026-03-25");
|
|
||||||
await endDateInput.blur();
|
|
||||||
await expect(page).toHaveURL(/dateTo=2026-03-25/);
|
await expect(page).toHaveURL(/dateTo=2026-03-25/);
|
||||||
|
|
||||||
await tabs.nth(0).click();
|
await tabs.nth(0).click();
|
||||||
@@ -377,7 +388,7 @@ test.describe("Admin overview night washes", () => {
|
|||||||
await tabs.nth(2).click();
|
await tabs.nth(2).click();
|
||||||
await expect(page.getByTestId("outside-hours-chart")).toBeVisible();
|
await expect(page.getByTestId("outside-hours-chart")).toBeVisible();
|
||||||
|
|
||||||
if (testInfo.project.use.isMobile) {
|
if (isCompactProject(testInfo)) {
|
||||||
await tabs.nth(0).click();
|
await tabs.nth(0).click();
|
||||||
await expect(page.getByTestId("daily-report-tile-night-washes")).toBeVisible();
|
await expect(page.getByTestId("daily-report-tile-night-washes")).toBeVisible();
|
||||||
await tabs.nth(1).click();
|
await tabs.nth(1).click();
|
||||||
|
|||||||
@@ -13,13 +13,47 @@ const POS_PERMISSIONS = [
|
|||||||
"get_custom_prices_other",
|
"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) {
|
async function primeOperatorSession(page: Page, token = "pos-orders-token", _permissions = POS_PERMISSIONS) {
|
||||||
await seedAuthenticatedState(page, token);
|
await seedAuthenticatedState(page, token);
|
||||||
const sessionRequest = page.waitForResponse((response) => {
|
await navigateToPosBootPage(page, { waitForSession: true });
|
||||||
return response.request().method() === "GET" && response.url().includes("/auth/session");
|
|
||||||
});
|
|
||||||
await page.goto("/login");
|
|
||||||
await sessionRequest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDisposableOrder(page: Page) {
|
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();
|
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) {
|
async function reloadOrderSettings(page: Page, orderId: number) {
|
||||||
await page.reload();
|
await revisitCurrentPage(page);
|
||||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||||
await clickVisibleTestId(page, "pos-order-tab-settings");
|
await clickVisibleTestId(page, "pos-order-tab-settings");
|
||||||
await expect(page.getByTestId("pos-order-panel-settings")).toBeVisible();
|
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) {
|
async function openOrderDetail(page: Page, orderId = 54518) {
|
||||||
await page.goto(`/admin/12/modules/pos/orders/${orderId}`);
|
const orderUrl = `/admin/12/modules/pos/orders/${orderId}`;
|
||||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
|
||||||
|
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) {
|
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((restoredMainBox?.width ?? 0) - (beforeMainBox?.width ?? 0))).toBeLessThanOrEqual(20);
|
||||||
expect(Math.abs((beforeRailBox?.width ?? 0) - (restoredRailBox?.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(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||||
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
|
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
|
||||||
await expectOrderTotal(page, 1397);
|
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 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 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/);
|
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 stepTwo = page.getByTestId("pos-step-2");
|
||||||
const orderCard = stepTwo.getByTestId("pos-order-card");
|
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);
|
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(stepTwo).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/admin\/12\/modules\/pos\?id=\d+&customer_id=12345679&step=2/);
|
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");
|
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 expect(noteInput).toBeFocused();
|
||||||
|
|
||||||
await page.reload();
|
await revisitCurrentPage(page);
|
||||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
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(referenceValue);
|
||||||
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(poValue);
|
await expect(page.getByTestId("pos-order-metadata-customer-wishes")).toContainText(poValue);
|
||||||
@@ -748,7 +800,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
|||||||
po: "",
|
po: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.reload();
|
await revisitCurrentPage(page);
|
||||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
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-reference")).toContainText("+ Tilføj");
|
||||||
await expect(page.getByTestId("pos-order-customer-wishes-po")).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 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-detail")).toBeVisible();
|
||||||
await expect(page.getByTestId("pos-order-registration-1")).toContainText("XY1234");
|
await expect(page.getByTestId("pos-order-registration-1")).toContainText("XY1234");
|
||||||
await expect(page.getByTestId("pos-order-registration-2")).toContainText("TR5678");
|
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 expect(page.getByTestId("pos-order-item-price-9101")).toContainText("1400");
|
||||||
await expectOrderTotal(page, 2123);
|
await expectOrderTotal(page, 2123);
|
||||||
|
|
||||||
await page.reload();
|
await revisitCurrentPage(page);
|
||||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
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-note-trigger-9101")).toBeVisible();
|
||||||
await expect(page.getByTestId("pos-order-item-reference-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 expect(page.getByTestId("pos-order-item-name-9102")).toContainText("Indvendig vask Forvogn");
|
||||||
await expectOrderTotal(page, 1423);
|
await expectOrderTotal(page, 1423);
|
||||||
|
|
||||||
await page.reload();
|
await revisitCurrentPage(page);
|
||||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
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-note-trigger-9102")).toBeVisible();
|
||||||
await expect(page.getByTestId("pos-order-item-price-9102")).toContainText("450");
|
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 expect(page.getByTestId("pos-order-item-delete-9103")).toHaveCount(0);
|
||||||
await expectOrderTotal(page, 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-detail")).toBeVisible();
|
||||||
await expect(page.getByTestId("pos-order-empty-state")).toBeVisible();
|
await expect(page.getByTestId("pos-order-empty-state")).toBeVisible();
|
||||||
await expectOrderTotal(page, 0);
|
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 expect(page.getByTestId("pos-order-item-name-9200").first()).toContainText("Forvogn");
|
||||||
await expectOrderTotal(page, 2021);
|
await expectOrderTotal(page, 2021);
|
||||||
|
|
||||||
await page.reload();
|
await revisitCurrentPage(page);
|
||||||
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
await expect(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||||
await expect(page.getByTestId("pos-order-item-edit-9200").first()).toBeVisible();
|
await expect(page.getByTestId("pos-order-item-edit-9200").first()).toBeVisible();
|
||||||
await expectOrderTotal(page, 2021);
|
await expectOrderTotal(page, 2021);
|
||||||
@@ -1856,7 +1908,7 @@ test.describe("Admin POS Orders - mobile smoke", () => {
|
|||||||
expect(restoredMainBox).not.toBeNull();
|
expect(restoredMainBox).not.toBeNull();
|
||||||
expect(Math.abs((restoredMainBox?.width ?? 0) - (beforeMainBox?.width ?? 0))).toBeLessThanOrEqual(20);
|
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(page.getByTestId("pos-order-detail")).toBeVisible();
|
||||||
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
|
await expect(getVisibleTestId(page, "pos-order-item-edit-9200")).toBeVisible();
|
||||||
await expectOrderTotal(page, 1397);
|
await expectOrderTotal(page, 1397);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { mockApi, primeMockSession } from "./support/network.js";
|
import { mockApi, primeMockSession } from "./support/network.js";
|
||||||
|
|
||||||
async function primeSuperuserSession(page) {
|
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.describe("Edge gateway route-aware flows", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await mockApi(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 }) => {
|
test("reveals and copies the rotated agent token after an explicit confirmation step", async ({ page }) => {
|
||||||
await page.goto("/superuser/gateways/701");
|
await page.goto("/superuser/gateways/701");
|
||||||
|
await openRecoverStep(page);
|
||||||
|
await openAdvancedOperations(page);
|
||||||
|
|
||||||
await page.getByTestId("gateway-rotate-credentials").click();
|
await page.getByTestId("gateway-rotate-credentials").click();
|
||||||
await expect(page.getByTestId("gateway-rotation-confirmation")).toBeVisible();
|
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");
|
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 }) => {
|
test("links the department-scoped gateway manager into the canonical gateway detail page", async ({ page }) => {
|
||||||
await page.goto("/superuser/departments/1/gateways");
|
await page.goto("/superuser/departments/1/gateways");
|
||||||
|
|
||||||
@@ -121,6 +156,7 @@ test.describe("Edge gateway background refresh", () => {
|
|||||||
await primeSuperuserSession(page);
|
await primeSuperuserSession(page);
|
||||||
|
|
||||||
await page.goto("/superuser/gateways/701");
|
await page.goto("/superuser/gateways/701");
|
||||||
|
await openRecoverStep(page);
|
||||||
|
|
||||||
const updateTimeline = page.getByTestId("gateway-update-timeline");
|
const updateTimeline = page.getByTestId("gateway-update-timeline");
|
||||||
const targetVersionField = page.getByLabel("Target-version");
|
const targetVersionField = page.getByLabel("Target-version");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { mockApi, primeMockSession } from "./support/network.js";
|
import { mockApi, primeMockSession } from "./support/network.js";
|
||||||
|
|
||||||
async function primeSuperuserSession(page) {
|
async function primeSuperuserSession(page) {
|
||||||
@@ -6,8 +6,21 @@ async function primeSuperuserSession(page) {
|
|||||||
await primeMockSession(page, { token });
|
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.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, {
|
await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
permissions: ["superuser", "user"],
|
permissions: ["superuser", "user"],
|
||||||
@@ -18,9 +31,8 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
|
|
||||||
await expect(page).toHaveURL(/\/superuser\/gateways$/);
|
await expect(page).toHaveURL(/\/superuser\/gateways$/);
|
||||||
await expect(page.locator("body")).toContainText("Edge gateways");
|
await expect(page.locator("body")).toContainText("Edge gateways");
|
||||||
await expect(page.locator("body")).toContainText("Valgt gateway");
|
await expect(page.getByTestId("gateway-step-shell-nav")).toContainText("Kategorier");
|
||||||
await expect(page.locator("body")).toContainText("CPH Edge 01");
|
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
|
||||||
await expect(page.locator("body")).toContainText("Shelly Plus 2PM");
|
|
||||||
await expect(page.locator("body")).toContainText("Latens");
|
await expect(page.locator("body")).toContainText("Latens");
|
||||||
await expect(page.locator("body")).toContainText("184 ms");
|
await expect(page.locator("body")).toContainText("184 ms");
|
||||||
await expect(page.locator("body")).toContainText("CPU");
|
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("Disk");
|
||||||
await expect(page.locator("body")).toContainText("58%");
|
await expect(page.locator("body")).toContainText("58%");
|
||||||
|
|
||||||
const bindingRows = page.locator(".edge-table--editor tbody tr");
|
await openConfigureStep(page);
|
||||||
const primaryBindingRow = bindingRows.nth(0);
|
await expect(page.getByTestId("gateway-step-configure-panel")).toContainText("Shelly Plus 2PM");
|
||||||
const legacyBindingRow = bindingRows.nth(1);
|
|
||||||
const primaryDeviceSelect = primaryBindingRow.locator("select").first();
|
const bindingCards = page.locator(".edge-binding-card");
|
||||||
const primaryChannelSelect = primaryBindingRow.locator("select").nth(1);
|
const primaryBindingCard = bindingCards.nth(0);
|
||||||
const primaryIpInput = primaryBindingRow.locator('input[type="text"]').nth(1);
|
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);
|
const primaryOnlineOption = primaryDeviceSelect.locator("option").nth(1);
|
||||||
await expect(primaryOnlineOption).toContainText("Shelly Plus 2PM");
|
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("Gen 3");
|
||||||
await expect(primaryOfflineOption).toContainText("Offline");
|
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("Ukendt Shelly-device");
|
||||||
await expect(staleOption).toContainText("shelly-missing-legacy");
|
await expect(staleOption).toContainText("shelly-missing-legacy");
|
||||||
await expect(staleOption).toContainText("10.1.0.99");
|
await expect(staleOption).toContainText("10.1.0.99");
|
||||||
@@ -61,36 +77,46 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
|
|
||||||
await primaryChannelSelect.selectOption("1");
|
await primaryChannelSelect.selectOption("1");
|
||||||
await primaryDeviceSelect.selectOption("shelly-mini-offline");
|
await primaryDeviceSelect.selectOption("shelly-mini-offline");
|
||||||
|
await primaryFallbackSelect.selectOption("LOCAL_ONLY");
|
||||||
await expect(primaryIpInput).toHaveValue("10.1.0.34");
|
await expect(primaryIpInput).toHaveValue("10.1.0.34");
|
||||||
await expect(primaryChannelSelect).toHaveValue("0");
|
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 relayBindingInput.fill("M-7-CANARY");
|
||||||
await page.getByRole("button", { name: "Gem bindinger" }).click();
|
await page.getByRole("button", { name: "Gem bindinger" }).click();
|
||||||
await expect(relayBindingInput).toHaveValue("M-7-CANARY");
|
await expect(relayBindingInput).toHaveValue("M-7-CANARY");
|
||||||
await expect(primaryDeviceSelect).toHaveValue("shelly-mini-offline");
|
await expect(primaryDeviceSelect).toHaveValue("shelly-mini-offline");
|
||||||
await expect(primaryIpInput).toHaveValue("10.1.0.34");
|
await expect(primaryIpInput).toHaveValue("10.1.0.34");
|
||||||
await expect(primaryChannelSelect).toHaveValue("0");
|
await expect(primaryChannelSelect).toHaveValue("0");
|
||||||
|
await expect(primaryFallbackSelect).toHaveValue("LOCAL_ONLY");
|
||||||
|
|
||||||
await page.getByLabel("Installer afdeling").selectOption("1");
|
await page.getByTestId("gateway-discovery-action").click();
|
||||||
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 expect(page.locator("body")).toContainText("Discovery er");
|
await expect(page.locator("body")).toContainText("Discovery er");
|
||||||
await expect(page.locator("body")).toContainText("Afventer");
|
await expect(page.locator("body")).toContainText("Afventer");
|
||||||
await expect(page.locator("body")).toContainText("shelly-plus-new");
|
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.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");
|
await expect(page.locator("body")).toContainText("Opdatering er");
|
||||||
const latestUpdate = page.getByTestId("gateway-update-item").first();
|
const latestUpdate = page.getByTestId("gateway-update-item").first();
|
||||||
await expect(latestUpdate).toContainText("1.3.0");
|
await expect(latestUpdate).toContainText("1.3.0");
|
||||||
await expect(latestUpdate).toContainText("Verificerer genstart");
|
await expect(latestUpdate).toContainText("Verificerer genstart");
|
||||||
await expect(latestUpdate).toContainText("Fuldført");
|
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("textbox", { name: /begrundelse/i }).fill("Investigate offline relay");
|
||||||
await page.getByRole("button", { name: "Godkend root shell" }).click();
|
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(page.locator("body")).toContainText("Live");
|
||||||
await expect(terminalBuffer).toContainText("[Live shell forbundet]");
|
await expect(terminalBuffer).toContainText("[Live shell forbundet]");
|
||||||
await expect(terminalBuffer).toContainText("root@pi:~#");
|
await expect(terminalBuffer).toContainText("root@pi:~#");
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.getSelection?.()?.toString() ?? ""))
|
||||||
|
.toBe("");
|
||||||
|
|
||||||
const terminalSurface = page.locator('[aria-label="Root shell terminal"]');
|
const terminalSurface = page.locator('[aria-label="Root shell terminal"]');
|
||||||
await terminalSurface.focus();
|
await terminalSurface.focus();
|
||||||
@@ -106,7 +135,7 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
await terminalSurface.press("Enter");
|
await terminalSurface.press("Enter");
|
||||||
await expect(terminalBuffer).toContainText("agent.mjs");
|
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(page.locator("body")).toContainText("Lukket");
|
||||||
await expect(terminalBuffer).toContainText("Session lukket");
|
await expect(terminalBuffer).toContainText("Session lukket");
|
||||||
});
|
});
|
||||||
@@ -124,19 +153,68 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
await primeSuperuserSession(page);
|
await primeSuperuserSession(page);
|
||||||
|
|
||||||
await page.goto("/superuser/gateways");
|
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("textbox", { name: /begrundelse/i }).fill("Test broken shell");
|
||||||
await page.getByRole("button", { name: "Godkend root shell" }).click();
|
await page.getByRole("button", { name: "Godkend root shell" }).click();
|
||||||
|
|
||||||
await expect(page.locator("body")).toContainText("Fejl");
|
await expect(page.locator("body")).toContainText("Fejlet");
|
||||||
await expect(page.getByRole("button", { name: "Terminate session" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "Afslut session" })).toBeDisabled();
|
||||||
await expect(page.locator('[aria-label="Root shell buffer"]')).toContainText("Gateway-shell blev lukket");
|
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, {
|
await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
permissions: ["superuser", "user"],
|
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);
|
await primeSuperuserSession(page);
|
||||||
|
|
||||||
@@ -144,13 +222,17 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
|
|
||||||
await expect(page).toHaveURL(/\/superuser\/departments\/1\/gateways$/);
|
await expect(page).toHaveURL(/\/superuser\/departments\/1\/gateways$/);
|
||||||
await expect(page.locator("body")).toContainText("Gateway for afdelingen");
|
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.getByLabel("Transporttilstand").selectOption("cloud");
|
||||||
await page.getByRole("button", { name: "Anvend cutover" }).click();
|
await page.getByRole("button", { name: "Anvend cutover" }).click();
|
||||||
await expect(page.getByLabel("Transporttilstand")).toHaveValue("cloud");
|
await expect(page.getByLabel("Transporttilstand")).toHaveValue("cloud");
|
||||||
await expect(page.locator("body")).toContainText("Cloud fallback");
|
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, {
|
await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
permissions: ["superuser", "user"],
|
permissions: ["superuser", "user"],
|
||||||
@@ -159,8 +241,9 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
|
|
||||||
await page.goto("/superuser/gateways");
|
await page.goto("/superuser/gateways");
|
||||||
|
|
||||||
await expect(page.locator("body")).toContainText("API polling");
|
await expect(page.locator("body")).toContainText("Broker fast path");
|
||||||
await page.getByRole("button", { name: /discovery/i }).click();
|
await openConfigureStep(page);
|
||||||
|
await page.getByTestId("gateway-discovery-action").click();
|
||||||
await expect(page.locator("body")).toContainText("Discovery er");
|
await expect(page.locator("body")).toContainText("Discovery er");
|
||||||
await expect(page.locator("body")).toContainText("shelly-plus-new");
|
await expect(page.locator("body")).toContainText("shelly-plus-new");
|
||||||
});
|
});
|
||||||
@@ -173,9 +256,10 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
await primeSuperuserSession(page);
|
await primeSuperuserSession(page);
|
||||||
|
|
||||||
await page.goto("/superuser/gateways/701");
|
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.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();
|
const latestUpdate = page.getByTestId("gateway-update-item").first();
|
||||||
await expect(latestUpdate).toContainText("1.3.0-rollback");
|
await expect(latestUpdate).toContainText("1.3.0-rollback");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { mockApi, primeMockSession } from "./support/network.js";
|
import { mockApi, primeMockSession } from "./support/network.js";
|
||||||
|
|
||||||
async function primeSuperuserSession(page) {
|
async function primeSuperuserSession(page) {
|
||||||
@@ -15,31 +15,27 @@ test.describe("Edge gateway visuals", () => {
|
|||||||
await primeSuperuserSession(page);
|
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");
|
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
||||||
|
|
||||||
await page.goto("/superuser/gateways");
|
await page.goto("/superuser/gateways");
|
||||||
const workspace = page.locator(".edge-workspace");
|
|
||||||
await expect(workspace).toContainText("Valgt gateway");
|
await expect(page.getByTestId("gateway-step-shell-nav")).toContainText("Kategorier");
|
||||||
await expect(workspace).toContainText("API polling");
|
await expect(page.getByTestId("gateway-step-assess")).toContainText("Vurder drift");
|
||||||
await expect(workspace).toContainText("One-line Raspberry Pi-installation");
|
await expect(page.getByTestId("gateway-step-configure")).toContainText("Konfigurer lokalt");
|
||||||
await expect(workspace).toContainText("Discovery på det lokale netværk");
|
await expect(page.getByTestId("gateway-step-recover")).toContainText("Gendan og vedligehold");
|
||||||
await expect(workspace).toContainText("Knyt relæer til lokale Shelly-enheder");
|
await expect(page.getByTestId("gateway-detail-header")).toContainText("Situationsbillede");
|
||||||
await expect(workspace).toContainText("Styr agent-versioner");
|
await expect(page.getByTestId("gateway-fleet-rail")).toContainText("Vælg en gateway");
|
||||||
await expect(workspace).toContainText("Break-glass adgang");
|
|
||||||
await expect(workspace).toContainText("Audit-log");
|
|
||||||
await expect(workspace).toHaveScreenshot("edge-gateways-desktop.png", {
|
|
||||||
maxDiffPixels: 250,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mobile hierarchy snapshot", async ({ page }, testInfo) => {
|
test("mobile tabbed hierarchy", async ({ page }, testInfo) => {
|
||||||
test.skip(!testInfo.project.name.includes("mobile"), "Mobile only");
|
test.skip(!testInfo.project.name.includes("mobile"), "Mobile only");
|
||||||
|
|
||||||
await page.goto("/superuser/gateways");
|
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", {
|
await expect(page.getByTestId("gateway-mobile-open-rail")).toBeVisible();
|
||||||
maxDiffPixels: 250,
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {}) {
|
function createEdgeGatewayFixture(options = {}) {
|
||||||
const primaryBrokerConnected = options.brokerConnected ?? true;
|
const primaryBrokerConnected = options.brokerConnected ?? true;
|
||||||
const gatewayOverridesById = new Map(
|
const gatewayOverridesById = new Map(
|
||||||
@@ -707,6 +777,7 @@ function createEdgeGatewayFixture(options = {}) {
|
|||||||
local_ip: "10.1.0.31",
|
local_ip: "10.1.0.31",
|
||||||
channel: 0,
|
channel: 0,
|
||||||
binding_source: "MANUAL",
|
binding_source: "MANUAL",
|
||||||
|
fallback_mode: "PREFER_LOCAL",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -715,6 +786,7 @@ function createEdgeGatewayFixture(options = {}) {
|
|||||||
local_ip: "10.1.0.99",
|
local_ip: "10.1.0.99",
|
||||||
channel: 1,
|
channel: 1,
|
||||||
binding_source: "MANUAL",
|
binding_source: "MANUAL",
|
||||||
|
fallback_mode: "CLOUD_ONLY",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
recent_updates: [{ id: 11, target_version: "1.2.1", status: "COMPLETED" }],
|
recent_updates: [{ id: 11, target_version: "1.2.1", status: "COMPLETED" }],
|
||||||
@@ -757,7 +829,9 @@ function createEdgeGatewayFixture(options = {}) {
|
|||||||
recent_commands: [],
|
recent_commands: [],
|
||||||
audit_logs: [{ id: 502, created_at: "2026-04-07 21:05:00", action: "HEARTBEAT_TIMEOUT", actor_type: "SYSTEM" }],
|
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 {
|
return {
|
||||||
pendingDiscoveryByGatewayId: {
|
pendingDiscoveryByGatewayId: {
|
||||||
@@ -766,6 +840,9 @@ function createEdgeGatewayFixture(options = {}) {
|
|||||||
pendingUpdateByGatewayId: {
|
pendingUpdateByGatewayId: {
|
||||||
...(options.pendingUpdateByGatewayId || {}),
|
...(options.pendingUpdateByGatewayId || {}),
|
||||||
},
|
},
|
||||||
|
pendingUninstallByGatewayId: {
|
||||||
|
...(options.pendingUninstallByGatewayId || {}),
|
||||||
|
},
|
||||||
shellSessionsById: {},
|
shellSessionsById: {},
|
||||||
gateways: baseGateways,
|
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;
|
return gateway;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,11 +986,21 @@ function createShellSessionFixture(edgeGatewayFixture, gatewayId, session, shell
|
|||||||
closed_at: "2026-04-09 12:45:30",
|
closed_at: "2026-04-09 12:45:30",
|
||||||
metadata: {
|
metadata: {
|
||||||
closed_reason: "open_failed",
|
closed_reason: "open_failed",
|
||||||
|
reconnect_state: "FAILED",
|
||||||
|
transport_path: "API_POLLING",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
pushEvent("OPENED", {});
|
pushEvent("OPENED", {});
|
||||||
pushEvent("OUTPUT", { data: shellConfig.prompt });
|
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;
|
edgeGatewayFixture.shellSessionsById[session.id] = state;
|
||||||
@@ -2178,7 +2299,9 @@ async function handleEdgeGatewayRoute({
|
|||||||
gateway.bindings = (body.bindings || []).map((binding, index) => ({
|
gateway.bindings = (body.bindings || []).map((binding, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
...binding,
|
...binding,
|
||||||
|
fallback_mode: binding.fallback_mode || "PREFER_LOCAL",
|
||||||
}));
|
}));
|
||||||
|
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
||||||
}
|
}
|
||||||
await route.fulfill(json({ data: gateway?.bindings || [] }));
|
await route.fulfill(json({ data: gateway?.bindings || [] }));
|
||||||
return true;
|
return true;
|
||||||
@@ -2210,6 +2333,47 @@ async function handleEdgeGatewayRoute({
|
|||||||
return true;
|
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") {
|
if (/\/edge-gateways\/\d+\/shell-sessions$/.test(pathname) && method === "POST") {
|
||||||
const gatewayId = Number(pathname.split("/")[2]);
|
const gatewayId = Number(pathname.split("/")[2]);
|
||||||
const gateway = edgeGatewayFixture.gateways.find((item) => item.id === gatewayId);
|
const gateway = edgeGatewayFixture.gateways.find((item) => item.id === gatewayId);
|
||||||
@@ -2221,6 +2385,8 @@ async function handleEdgeGatewayRoute({
|
|||||||
expires_at: "2026-04-09 12:45:00",
|
expires_at: "2026-04-09 12:45:00",
|
||||||
metadata: {
|
metadata: {
|
||||||
transport: "API_POLLING",
|
transport: "API_POLLING",
|
||||||
|
transport_path: "API_POLLING",
|
||||||
|
reconnect_state: "PENDING",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (gateway) {
|
if (gateway) {
|
||||||
@@ -2346,6 +2512,26 @@ async function handleEdgeGatewayRoute({
|
|||||||
return true;
|
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") {
|
if (/\/departments\/\d+\/gateway-cutover$/.test(pathname) && method === "POST") {
|
||||||
const departmentId = Number(pathname.split("/")[2]);
|
const departmentId = Number(pathname.split("/")[2]);
|
||||||
const body = request.postDataJSON?.() || {};
|
const body = request.postDataJSON?.() || {};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
@@ -6,6 +6,14 @@ const workspaceSource = readFileSync(
|
|||||||
join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue"),
|
join(process.cwd(), "src/components/displays/edgeGateway/EdgeGatewayWorkspace.vue"),
|
||||||
"utf8"
|
"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 routerSource = readFileSync(join(process.cwd(), "src/router.js"), "utf8");
|
||||||
const navSource = readFileSync(
|
const navSource = readFileSync(
|
||||||
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue"),
|
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue"),
|
||||||
@@ -21,78 +29,33 @@ const departmentGatewaysViewSource = readFileSync(
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe("edge gateway workspace contract", () => {
|
describe("edge gateway workspace contract", () => {
|
||||||
it("renders the redesigned workflow sections and delegates root shell UI to the terminal component", () => {
|
it("renders the guided workflow container through focused child components", () => {
|
||||||
expect(workspaceSource).toContain("Valgt gateway");
|
expect(workspaceSource).toContain('import { useEdgeGatewayWorkspace } from "@/composables/useEdgeGatewayWorkspace.js";');
|
||||||
expect(workspaceSource).toContain("Overblik");
|
expect(workspaceSource).toContain('import EdgeGatewayFleetRail from "./EdgeGatewayFleetRail.vue";');
|
||||||
expect(workspaceSource).toContain("Installation");
|
expect(workspaceSource).toContain('import EdgeGatewayContextPanel from "./EdgeGatewayContextPanel.vue";');
|
||||||
expect(workspaceSource).toContain("Shelly-enheder");
|
expect(workspaceSource).toContain('import EdgeGatewayHealthSummary from "./EdgeGatewayHealthSummary.vue";');
|
||||||
expect(workspaceSource).toContain("Bindinger");
|
expect(workspaceSource).toContain('import EdgeGatewayConfigurePanel from "./EdgeGatewayConfigurePanel.vue";');
|
||||||
expect(workspaceSource).toContain("Opdateringer");
|
expect(workspaceSource).toContain('import EdgeGatewayMaintenancePanel from "./EdgeGatewayMaintenancePanel.vue";');
|
||||||
expect(workspaceSource).toContain("Historik");
|
expect(workspaceSource).toContain('import EdgeGatewayAdvancedOperations from "./EdgeGatewayAdvancedOperations.vue";');
|
||||||
expect(workspaceSource).toContain('import EdgeGatewayTerminal from "./EdgeGatewayTerminal.vue";');
|
expect(workspaceSource).not.toContain('import EdgeGatewayTerminal from "./EdgeGatewayTerminal.vue";');
|
||||||
expect(workspaceSource).toContain("<EdgeGatewayTerminal");
|
expect(workspaceSource).toContain('data-testid="gateway-step-shell-nav"');
|
||||||
expect(workspaceSource).toContain('@session-approved="handleShellSessionApproved"');
|
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", () => {
|
it("keeps route-aware props and page-level selection wiring intact", () => {
|
||||||
expect(workspaceSource).toContain("Generér installer");
|
expect(workspaceSource).toContain("selectedGatewayId: {");
|
||||||
expect(workspaceSource).toContain('aria-label="Installer afdeling"');
|
expect(workspaceSource).toContain('defineEmits(["open-gateway-page", "select-gateway"])');
|
||||||
expect(workspaceSource).toContain("Kør discovery");
|
expect(edgeGatewaysViewSource).toContain('route.name === "edgegatewaydetail"');
|
||||||
expect(workspaceSource).toContain("Styringskanal");
|
expect(edgeGatewaysViewSource).toContain(':selected-gateway-id="selectedGatewayId"');
|
||||||
expect(workspaceSource).toContain("Latens");
|
expect(edgeGatewaysViewSource).toContain('@select-gateway="handleGatewaySelection"');
|
||||||
expect(workspaceSource).toContain("CPU");
|
expect(departmentGatewaysViewSource).toContain('@open-gateway-page="openGatewayPage"');
|
||||||
expect(workspaceSource).toContain("RAM");
|
expect(departmentGatewaysViewSource).toContain('name: "edgegatewaydetail"');
|
||||||
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("builds richer binding device options and keeps stale selections editable", () => {
|
it("keeps canonical fleet and department routes registered", () => {
|
||||||
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", () => {
|
|
||||||
expect(routerSource).toContain("path: '/superuser/gateways'");
|
expect(routerSource).toContain("path: '/superuser/gateways'");
|
||||||
expect(routerSource).toContain("name: 'edgegatewaydetail'");
|
expect(routerSource).toContain("name: 'edgegatewaydetail'");
|
||||||
expect(routerSource).toContain("path: '/superuser/gateways/:id'");
|
expect(routerSource).toContain("path: '/superuser/gateways/:id'");
|
||||||
@@ -100,17 +63,17 @@ describe("edge gateway workspace contract", () => {
|
|||||||
expect(navSource).toContain("'/superuser/departments/' + departmentId.value + '/gateways'");
|
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", () => {
|
it("delegates risky operations and the root shell surface to the advanced operations panel", () => {
|
||||||
expect(workspaceSource).toContain("selectedGatewayId: {");
|
expect(workspaceSource).toContain('<EdgeGatewayAdvancedOperations');
|
||||||
expect(workspaceSource).toContain('defineEmits(["open-gateway-page", "select-gateway"])');
|
expect(advancedOperationsSource).toContain('import EdgeGatewayTerminal from "./EdgeGatewayTerminal.vue";');
|
||||||
expect(workspaceSource).toContain(
|
expect(advancedOperationsSource).toContain('data-testid="gateway-advanced-operations"');
|
||||||
"const routeSelectedGatewayId = computed(() => normalizeGatewayId(props.selectedGatewayId));"
|
expect(advancedOperationsSource).toContain('data-testid="gateway-step-shell"');
|
||||||
);
|
expect(advancedOperationsSource).toContain('data-testid="gateway-relay-health-table"');
|
||||||
expect(workspaceSource).toContain('data-testid="gateway-unavailable-state"');
|
expect(advancedOperationsSource).toContain('data-testid="gateway-rotate-credentials"');
|
||||||
expect(edgeGatewaysViewSource).toContain('route.name === "edgegatewaydetail"');
|
expect(advancedOperationsSource).toContain('data-testid="gateway-cutover-apply"');
|
||||||
expect(edgeGatewaysViewSource).toContain(':selected-gateway-id="selectedGatewayId"');
|
expect(advancedOperationsSource).toContain('data-testid="gateway-uninstall"');
|
||||||
expect(edgeGatewaysViewSource).toContain('@select-gateway="handleGatewaySelection"');
|
expect(advancedOperationsSource).toContain('data-testid="gateway-delete"');
|
||||||
expect(departmentGatewaysViewSource).toContain('@open-gateway-page="openGatewayPage"');
|
expect(advancedOperationsSource).toContain('data-testid="gateway-uninstall-confirmation"');
|
||||||
expect(departmentGatewaysViewSource).toContain('name: "edgegatewaydetail"');
|
expect(advancedOperationsSource).toContain('data-testid="gateway-delete-confirmation"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -233,6 +233,64 @@ describe("useEdgeGatewayTerminal", () => {
|
|||||||
unmount();
|
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 () => {
|
it("surfaces polling failures before the shell opens", async () => {
|
||||||
const fakeTerminal = createFakeTerminal();
|
const fakeTerminal = createFakeTerminal();
|
||||||
const shellApi = createShellApi("fail-before-open");
|
const shellApi = createShellApi("fail-before-open");
|
||||||
|
|||||||
Reference in New Issue
Block a user