Add self-serve Edge Agents management with navigation, routes, and tabs. Update legacy redirects and extend unit and E2E tests.

This commit is contained in:
Jeppe Bundgaard
2026-04-28 11:32:23 +02:00
parent 61aab76e63
commit 4b02de7358
26 changed files with 1263 additions and 230 deletions
@@ -1186,7 +1186,7 @@ const openDepartmentWorkspaceTab = (tabId) =>
const openPrimaryGatewayPage = async () => {
if (!props.department_id) {
SessionUser.functions.redirectTo.superUser("/configuration/edgegateway", true);
SessionUser.functions.redirectTo.superUser("/selfserve/edge-agents", true);
return;
}
@@ -1199,7 +1199,7 @@ const openPrimaryGatewayPage = async () => {
if (primaryGatewayId) {
SessionUser.functions.redirectTo.superUser(
`/configuration/edgegateway/${encodeURIComponent(String(primaryGatewayId))}/overview`,
`/selfserve/edge-agents/${encodeURIComponent(String(primaryGatewayId))}/overview`,
true
);
return;
@@ -1208,7 +1208,7 @@ const openPrimaryGatewayPage = async () => {
console.warn("Unable to resolve primary gateway from department workspace", error);
}
SessionUser.functions.redirectTo.superUser("/configuration/edgegateway", true);
SessionUser.functions.redirectTo.superUser("/selfserve/edge-agents", true);
};
const flatBuiltInMenuSections = computed(() => {
@@ -1528,7 +1528,7 @@ const flatBuiltInMenuSections = computed(() => {
? buildMenuAction("department-gateways-open-fleet", {
icon: "fas fa-sitemap",
label: t("admin.pos.settings_wheel.open_fleet_landing"),
clickAction: () => SessionUser.functions.redirectTo.superUser("/configuration/edgegateway", true),
clickAction: () => SessionUser.functions.redirectTo.superUser("/selfserve/edge-agents", true),
})
: null,
SessionUser.canAccessSuperUser()
@@ -119,7 +119,7 @@ const gatewayCards = computed(() => {
lastHeartbeatLabel: formatDate(gateway?.last_heartbeat_at),
activeOperationLabel: gatewayOperationLabel(activeOperation),
message: gatewayPrimaryMessage(gateway),
link: `/superuser/configuration/edgegateway/${encodeURIComponent(String(gateway?.id ?? ""))}/overview`,
link: `/superuser/selfserve/edge-agents/${encodeURIComponent(String(gateway?.id ?? ""))}/overview`,
};
})
.filter((gateway) => gateway.id !== null);
@@ -702,7 +702,7 @@ function modulePath(key) {
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
</div>
<RouterLink class="section-link" to="/superuser/configuration/edgegateway">
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
</div>
@@ -324,6 +324,7 @@ const items = computed<NavigationItemProps[]>(() => [
{ label: t('superuser.nav.scanners'), to: '/superuser/scanners' },
{ label: t('superuser.nav.selfserve'), to: '/superuser/selfserve' },
{ label: 'Self-serve sessions', to: '/superuser/selfserve/sessions' },
{ label: 'Edge Agents', to: '/superuser/selfserve/edge-agents' },
]},
// Configurations
{ label: t('superuser.nav.configuration'), type: 'category', children: [
@@ -1,5 +1,9 @@
<script>
import { getEdgeGatewayModuleConfig, setEdgeGatewayModuleConfig } from "@/services/edgeGateways.js";
import {
getEdgeGatewayModuleConfig,
setEdgeGatewayModuleConfig,
testEdgeGatewayBrokerConfig,
} from "@/services/edgeGateways.js";
const normalizeEntries = (entries) => {
if (Array.isArray(entries)) {
@@ -35,6 +39,7 @@ export const Config = {
}),
get_config: async () => Config.get_all().then((response) => response?.data?.config || {}),
set_config: async (payload) => setEdgeGatewayModuleConfig(payload),
test_broker: async (payload) => testEdgeGatewayBrokerConfig(payload),
set: async (variable, value) => {
const currentConfig = await Config.get_config();
return Config.set_config({
@@ -506,17 +506,17 @@ const openGatewayPage = async (gatewayId) => {
return;
}
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/overview`);
await router.push(`/superuser/selfserve/edge-agents/${encodeURIComponent(String(gatewayId))}/overview`);
};
const openPrimaryGatewayInventory = async () => {
const gatewayId = summary.value?.primary_gateway?.id || null;
if (!gatewayId) {
await router.push("/superuser/configuration/edgegateway");
await router.push("/superuser/selfserve/edge-agents");
return;
}
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/inventory`);
await router.push(`/superuser/selfserve/edge-agents/${encodeURIComponent(String(gatewayId))}/inventory`);
};
const saveScannerLane = async (scanner) => {
@@ -819,7 +819,7 @@ const getRelayConsumerContextLabel = (context) => {
class="button is-dark"
type="button"
data-testid="department-hardware-open-fleet"
@click="openPath('/superuser/configuration/edgegateway')"
@click="openPath('/superuser/selfserve/edge-agents')"
>
Open Fleet Landing
</button>
@@ -69,7 +69,7 @@ const openPrimaryGateway = async (gatewayId) => {
return;
}
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/overview`);
await router.push(`/superuser/selfserve/edge-agents/${encodeURIComponent(String(gatewayId))}/overview`);
};
onMounted(loadSummaries);
+626 -48
View File
@@ -26,12 +26,146 @@ const props = defineProps({
const levelFilter = ref("ALL");
const typeFilter = ref("ALL");
const expandedTimelineKeys = ref(new Set());
const expandedRelayKeys = ref(new Set());
const activeContextModal = ref(null);
const copiedContextKey = ref("");
const entryContext = (entry = {}) => {
const context = entry?.context || entry?.entry?.context || {};
return context && typeof context === "object" && !Array.isArray(context) ? context : {};
};
const formatDetailValue = (value) => {
if (value === null || typeof value === "undefined" || value === "") {
return "None";
}
if (typeof value === "boolean") {
return value ? "yes" : "no";
}
if (typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
return String(value);
};
const detailRow = (label, value) => ({
label,
value: formatDetailValue(value),
multiline: value !== null && typeof value === "object",
});
const contextDetailRow = (value) => ({
label: "Context",
value: formatDetailValue(value),
multiline: true,
actionsOnly: true,
});
const pushOptionalDetailRow = (rows, label, value) => {
if (value !== null && typeof value !== "undefined" && value !== "") {
rows.push(detailRow(label, value));
}
};
const toggleExpandedKey = (expandedRef, key) => {
const next = new Set(expandedRef.value);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
expandedRef.value = next;
};
const timelineEntryKey = (entry = {}, index = 0) =>
[
"timeline",
entry?.type || "log",
entry?.created_at || "no-time",
entry?.message || "no-message",
entry?.entry?.id || entry?.id || index,
].join(":");
const relayEntryKey = (entry = {}, index = 0) =>
["relay", entry?.id || entry?.created_at || "no-time", entry?.message || index].join(":");
const isTimelineExpanded = (entry = {}, index = 0) => expandedTimelineKeys.value.has(timelineEntryKey(entry, index));
const isRelayExpanded = (entry = {}, index = 0) => expandedRelayKeys.value.has(relayEntryKey(entry, index));
const toggleTimelineEntry = (entry = {}, index = 0) => {
toggleExpandedKey(expandedTimelineKeys, timelineEntryKey(entry, index));
};
const toggleRelayEntry = (entry = {}, index = 0) => {
toggleExpandedKey(expandedRelayKeys, relayEntryKey(entry, index));
};
const closeContextModal = () => {
activeContextModal.value = null;
};
const openContextModal = (title, value) => {
activeContextModal.value = {
title,
value,
};
};
const writeToClipboard = async (value) => {
const text = String(value || "");
if (!text) {
return false;
}
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
if (typeof document === "undefined" || !document.body) {
return false;
}
const helper = document.createElement("textarea");
helper.value = text;
helper.setAttribute("readonly", "readonly");
helper.style.position = "fixed";
helper.style.opacity = "0";
helper.style.pointerEvents = "none";
document.body.appendChild(helper);
helper.focus();
helper.select();
let copied = false;
try {
copied = document.execCommand("copy");
} finally {
document.body.removeChild(helper);
}
return copied;
};
const copyContextValue = async (value, key) => {
const copied = await writeToClipboard(value).catch(() => false);
if (!copied) {
return;
}
copiedContextKey.value = key;
window.setTimeout(() => {
if (copiedContextKey.value === key) {
copiedContextKey.value = "";
}
}, 1600);
};
const relayEntries = computed(() => {
if (props.relayLogs.length) {
return props.relayLogs;
@@ -43,7 +177,8 @@ const relayEntries = computed(() => {
.filter(Boolean);
});
const relayModuleLabel = (entry = {}) => entryContext(entry).module_responsible || entryContext(entry).module || "edge_gateway";
const relayModuleLabel = (entry = {}) =>
entryContext(entry).module_responsible || entryContext(entry).module || "edge_gateway";
const relayHandlerLabel = (entry = {}) => {
const context = entryContext(entry);
@@ -72,14 +207,116 @@ const relayActorLabel = (entry = {}) => {
return parts.join(" / ") || "No actor";
};
const relaySignalLabel = (entry = {}) => {
const normalizeRelayRole = (role = "") => {
const normalized = String(role || "").trim().toUpperCase();
const aliases = {
ENTRANCE: "ENTRY",
IN: "ENTRY",
INLET: "ENTRY",
ENTRY_GATE: "ENTRY",
OUT: "EXIT",
OUTLET: "EXIT",
EXIT_GATE: "EXIT",
MACHINE_PROGRAM_PICKER: "PROGRAM_PICKER",
PROGRAM_SELECTOR: "PROGRAM_PICKER",
PICKER: "PROGRAM_PICKER",
MACHINE_CLEANER: "CLEANER",
};
return aliases[normalized] || normalized;
};
const relayRequestPayload = (entry = {}) => {
const context = entryContext(entry);
const signal = context.signal && typeof context.signal === "object" ? context.signal : {};
return signal.request && typeof signal.request === "object" ? signal.request : {};
};
const relayStateValue = (entry = {}) => {
const context = entryContext(entry);
const request = relayRequestPayload(entry);
const response = context.response && typeof context.response === "object" ? context.response : {};
if (Object.prototype.hasOwnProperty.call(context, "target_on") && context.target_on !== null) {
return Boolean(context.target_on);
}
if (Object.prototype.hasOwnProperty.call(request, "on")) {
return Boolean(request.on);
}
if (Object.prototype.hasOwnProperty.call(response, "on")) {
return Boolean(response.on);
}
return null;
};
const firstRelayText = (values = []) => {
for (const value of values) {
const text = String(value || "").trim();
if (text) {
return text;
}
}
return "";
};
const relayDisplayName = (entry = {}) => {
const context = entryContext(entry);
const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const binding = context.binding && typeof context.binding === "object" ? context.binding : {};
const request = relayRequestPayload(entry);
return firstRelayText([
context.relay_name,
context.relay_label,
actionContext.relay_name,
actionContext.relay_label,
binding.relay_name,
binding.name,
request.relay_name,
request.relay_label,
]);
};
const relayIdentifier = (entry = {}) => {
const context = entryContext(entry);
const signal = context.signal && typeof context.signal === "object" ? context.signal : {};
const request = relayRequestPayload(entry);
return context.relay_id || signal.relay_id || request.relayId || request.id || "unknown";
};
const relaySignalLabel = (entry = {}) => {
const context = entryContext(entry);
const actionContext = context.action_context && typeof context.action_context === "object" ? context.action_context : {};
const signal = context.signal && typeof context.signal === "object" ? context.signal : {};
const role = normalizeRelayRole(context.relay_role || actionContext.relay_role || actionContext.role);
const action = String(context.action || signal.command_type || "RELAY").toUpperCase();
const relayId = context.relay_id || signal.relay_id || signal.request?.relayId || signal.request?.id || "unknown";
const targetOn = Object.prototype.hasOwnProperty.call(context, "target_on") ? context.target_on : signal.request?.on;
const target = typeof targetOn === "boolean" ? (targetOn ? "ON" : "OFF") : "";
return [action, relayId, target].filter(Boolean).join(" ");
const relayTarget = relayDisplayName(entry) || relayIdentifier(entry);
const state = relayStateValue(entry);
if (["ENTRY", "EXIT"].includes(role)) {
return ["Open", role, relayTarget].filter(Boolean).join(" ");
}
if (["MACHINE", "PROGRAM_PICKER", "CLEANER"].includes(role) && state !== null) {
return [role, state ? "ON" : "OFF", relayTarget].filter(Boolean).join(" ");
}
const target = state !== null ? (state ? "ON" : "OFF") : "";
return [action, relayTarget, target].filter(Boolean).join(" ");
};
const relayDescription = (entry = {}) => {
const context = entryContext(entry);
const existing = firstRelayText([context.description, entry.description]);
if (existing) {
return existing;
}
const subject = relaySignalLabel(entry);
const handler = String(context.handler || context.execution_path || "local").toLowerCase();
const channel = context.delivery_channel || context.execution?.channel || "";
const failed = context.success === false || String(entry.level || "").toUpperCase() === "ERROR";
return failed
? `${subject} failed via ${handler}`
: `${subject} handled by ${handler}${channel ? ` via ${channel}` : ""}`;
};
const relayResponseLabel = (entry = {}) => {
@@ -94,6 +331,59 @@ const relayResponseLabel = (entry = {}) => {
return parts.join(" / ") || "No response payload";
};
const timelineEntryMessage = (entry = {}) => {
const rawEntry = entry?.entry && typeof entry.entry === "object" ? entry.entry : {};
const type = String(entry.type || rawEntry.stream || "").toLowerCase();
if (type === "relay") {
return relayDescription(rawEntry);
}
return entry.message || rawEntry.message || rawEntry.action || rawEntry.code || "No message";
};
const timelineDetailRows = (entry = {}) => {
const rawEntry = entry?.entry && typeof entry.entry === "object" ? entry.entry : {};
const context = entryContext(entry);
const rows = [
detailRow("Level", entry.level || rawEntry.level || rawEntry.severity || "INFO"),
detailRow("Type", entry.type || rawEntry.stream || "log"),
detailRow("Message", timelineEntryMessage(entry)),
detailRow("Timestamp", entry.created_at || rawEntry.created_at || "No timestamp"),
];
pushOptionalDetailRow(rows, "ID", rawEntry.id || entry.id);
pushOptionalDetailRow(rows, "Source", rawEntry.source);
pushOptionalDetailRow(rows, "Stream", rawEntry.stream);
pushOptionalDetailRow(rows, "Operation", rawEntry.operation_type);
pushOptionalDetailRow(rows, "Operation ID", rawEntry.operation_id);
if (Object.keys(context).length) {
rows.push(contextDetailRow(context));
}
return rows;
};
const relayDetailRows = (entry = {}) => {
const context = entryContext(entry);
const rows = [
detailRow("Handler", relayHandlerLabel(entry)),
detailRow("Module", relayModuleLabel(entry)),
detailRow("Message", relayDescription(entry)),
detailRow("Signal", relaySignalLabel(entry)),
detailRow("Response", relayResponseLabel(entry)),
detailRow("Actor", relayActorLabel(entry)),
detailRow("Reason", relayReasonLabel(entry)),
detailRow("Timestamp", entry.created_at || "No timestamp"),
];
pushOptionalDetailRow(rows, "ID", entry.id);
pushOptionalDetailRow(rows, "Source", entry.source);
if (Object.keys(context).length) {
rows.push(contextDetailRow(context));
}
return rows;
};
const filteredTimeline = computed(() =>
props.timeline.filter((entry) => {
if (levelFilter.value !== "ALL" && String(entry.level || "").toUpperCase() !== levelFilter.value) {
@@ -143,15 +433,65 @@ const filteredTimeline = computed(() =>
<p class="edge-gateway-logs__eyebrow">Timeline</p>
<h3>Live and persisted entries</h3>
<ul class="edge-gateway-logs__timeline" data-testid="gateway-logs-timeline">
<li v-for="entry in filteredTimeline" :key="`${entry.type}-${entry.created_at}-${entry.message}`">
<div>
<strong>{{ entry.level || "INFO" }}</strong>
<small>{{ entry.type || "log" }}</small>
</div>
<p>{{ entry.message || "No message" }}</p>
<small>{{ entry.created_at || "No timestamp" }}</small>
<li
v-for="(entry, index) in filteredTimeline"
:key="timelineEntryKey(entry, index)"
class="edge-gateway-logs__entry"
:class="{ 'edge-gateway-logs__entry--expanded': isTimelineExpanded(entry, index) }"
>
<button
type="button"
class="edge-gateway-logs__entry-toggle"
:aria-expanded="isTimelineExpanded(entry, index)"
:aria-controls="`gateway-log-entry-details-${index}`"
:data-testid="`gateway-log-entry-${index}`"
@click="toggleTimelineEntry(entry, index)"
>
<span class="edge-gateway-logs__entry-heading">
<span class="edge-gateway-logs__entry-labels">
<strong>{{ entry.level || "INFO" }}</strong>
<small>{{ entry.type || "log" }}</small>
</span>
<span class="edge-gateway-logs__expand-indicator" aria-hidden="true"></span>
</span>
<span class="edge-gateway-logs__summary">{{ timelineEntryMessage(entry) }}</span>
</button>
<dl
v-if="isTimelineExpanded(entry, index)"
:id="`gateway-log-entry-details-${index}`"
class="edge-gateway-logs__entry-details"
:data-testid="`gateway-log-entry-details-${index}`"
>
<div v-for="row in timelineDetailRows(entry)" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>
<span v-if="row.actionsOnly" class="edge-gateway-logs__context-actions">
<button
type="button"
class="button is-small is-light"
:data-testid="`gateway-log-entry-context-view-${index}`"
@click="openContextModal('Timeline context', row.value)"
>
View context
</button>
<button
type="button"
class="button is-small is-light"
:data-testid="`gateway-log-entry-context-copy-${index}`"
@click="copyContextValue(row.value, `timeline-${index}`)"
>
{{ copiedContextKey === `timeline-${index}` ? "Copied" : "Copy" }}
</button>
</span>
<pre v-else-if="row.multiline">{{ row.value }}</pre>
<span v-else>{{ row.value }}</span>
</dd>
</div>
</dl>
</li>
<li v-if="!filteredTimeline.length" class="edge-gateway-logs__empty">
No log entries match the current filter.
</li>
<li v-if="!filteredTimeline.length">No log entries match the current filter.</li>
</ul>
</article>
@@ -159,33 +499,63 @@ const filteredTimeline = computed(() =>
<p class="edge-gateway-logs__eyebrow">Relay dispatch</p>
<h3>Signals and responses</h3>
<ul class="edge-gateway-logs__timeline" data-testid="gateway-relay-logs">
<li v-for="entry in relayEntries" :key="`relay-${entry.id || entry.created_at || entry.message}`">
<div>
<strong>{{ relayHandlerLabel(entry) }}</strong>
<small>{{ relayModuleLabel(entry) }}</small>
</div>
<p>{{ entry.message || relaySignalLabel(entry) }}</p>
<dl class="edge-gateway-logs__relay-details">
<div>
<dt>Signal</dt>
<dd>{{ relaySignalLabel(entry) }}</dd>
</div>
<div>
<dt>Response</dt>
<dd>{{ relayResponseLabel(entry) }}</dd>
</div>
<div>
<dt>Actor</dt>
<dd>{{ relayActorLabel(entry) }}</dd>
</div>
<div>
<dt>Reason</dt>
<dd>{{ relayReasonLabel(entry) }}</dd>
<li
v-for="(entry, index) in relayEntries"
:key="relayEntryKey(entry, index)"
class="edge-gateway-logs__entry"
:class="{ 'edge-gateway-logs__entry--expanded': isRelayExpanded(entry, index) }"
>
<button
type="button"
class="edge-gateway-logs__entry-toggle"
:aria-expanded="isRelayExpanded(entry, index)"
:aria-controls="`gateway-relay-entry-details-${index}`"
:data-testid="`gateway-relay-entry-${index}`"
@click="toggleRelayEntry(entry, index)"
>
<span class="edge-gateway-logs__entry-heading">
<span class="edge-gateway-logs__entry-labels">
<strong>{{ relayHandlerLabel(entry) }}</strong>
<small>{{ relayModuleLabel(entry) }}</small>
</span>
<span class="edge-gateway-logs__expand-indicator" aria-hidden="true"></span>
</span>
<span class="edge-gateway-logs__summary">{{ relayDescription(entry) }}</span>
</button>
<dl
v-if="isRelayExpanded(entry, index)"
:id="`gateway-relay-entry-details-${index}`"
class="edge-gateway-logs__entry-details"
:data-testid="`gateway-relay-entry-details-${index}`"
>
<div v-for="row in relayDetailRows(entry)" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>
<span v-if="row.actionsOnly" class="edge-gateway-logs__context-actions">
<button
type="button"
class="button is-small is-light"
:data-testid="`gateway-relay-entry-context-view-${index}`"
@click="openContextModal('Relay context', row.value)"
>
View context
</button>
<button
type="button"
class="button is-small is-light"
:data-testid="`gateway-relay-entry-context-copy-${index}`"
@click="copyContextValue(row.value, `relay-${index}`)"
>
{{ copiedContextKey === `relay-${index}` ? "Copied" : "Copy" }}
</button>
</span>
<pre v-else-if="row.multiline">{{ row.value }}</pre>
<span v-else>{{ row.value }}</span>
</dd>
</div>
</dl>
<small>{{ entry.created_at || "No timestamp" }}</small>
</li>
<li v-if="!relayEntries.length">No relay dispatch entries recorded.</li>
<li v-if="!relayEntries.length" class="edge-gateway-logs__empty">No relay dispatch entries recorded.</li>
</ul>
</article>
@@ -205,6 +575,46 @@ const filteredTimeline = computed(() =>
</ul>
</article>
</div>
<div
v-if="activeContextModal"
class="modal is-active edge-gateway-logs__context-modal"
role="dialog"
aria-modal="true"
aria-labelledby="gateway-context-modal-title"
data-testid="gateway-context-modal"
tabindex="-1"
@keyup.esc="closeContextModal"
>
<div class="modal-background" @click="closeContextModal"></div>
<div class="modal-card edge-gateway-logs__context-modal-card">
<header class="modal-card-head">
<p id="gateway-context-modal-title" class="modal-card-title">{{ activeContextModal.title }}</p>
<button class="delete" type="button" aria-label="close" @click="closeContextModal"></button>
</header>
<section class="modal-card-body edge-gateway-logs__context-modal-body">
<pre data-testid="gateway-context-modal-body">{{ activeContextModal.value }}</pre>
</section>
<footer class="modal-card-foot edge-gateway-logs__context-modal-footer">
<button
type="button"
class="button is-light"
data-testid="gateway-context-modal-copy"
@click="copyContextValue(activeContextModal.value, 'modal')"
>
{{ copiedContextKey === "modal" ? "Copied" : "Copy" }}
</button>
<button
type="button"
class="button is-dark"
data-testid="gateway-context-modal-close"
@click="closeContextModal"
>
Close
</button>
</footer>
</div>
</div>
</section>
</template>
@@ -250,7 +660,7 @@ const filteredTimeline = computed(() =>
padding: 0;
list-style: none;
display: grid;
gap: 0.75rem;
gap: 0.55rem;
}
.edge-gateway-logs__timeline li {
@@ -265,32 +675,200 @@ const filteredTimeline = computed(() =>
color: #334155;
}
.edge-gateway-logs__relay-details {
margin: 0.75rem 0;
display: grid;
gap: 0.45rem;
.edge-gateway-logs__timeline li.edge-gateway-logs__entry {
padding: 0;
overflow: hidden;
border-radius: 12px;
}
.edge-gateway-logs__relay-details div {
.edge-gateway-logs__entry-toggle {
appearance: none;
width: 100%;
border: 0;
background: transparent;
padding: 0.55rem 0.75rem;
color: inherit;
font: inherit;
cursor: pointer;
text-align: left;
display: grid;
gap: 0.25rem;
}
.edge-gateway-logs__entry-toggle:focus-visible {
outline: 2px solid #2563eb;
outline-offset: -2px;
}
.edge-gateway-logs__entry-heading {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.edge-gateway-logs__entry-labels {
min-width: 0;
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.edge-gateway-logs__entry-labels strong,
.edge-gateway-logs__entry-labels small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.edge-gateway-logs__entry-labels strong {
color: #1f2937;
font-size: 0.9rem;
}
.edge-gateway-logs__entry-labels small {
color: #64748b;
font-size: 0.78rem;
}
.edge-gateway-logs__summary {
min-width: 0;
overflow: hidden;
color: #334155;
font-size: 0.9rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.edge-gateway-logs__expand-indicator {
position: relative;
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #ffffff;
}
.edge-gateway-logs__expand-indicator::before,
.edge-gateway-logs__expand-indicator::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0.5rem;
height: 1px;
background: #475569;
transform: translate(-50%, -50%);
}
.edge-gateway-logs__expand-indicator::after {
transform: translate(-50%, -50%) rotate(90deg);
}
.edge-gateway-logs__entry--expanded .edge-gateway-logs__expand-indicator::after {
opacity: 0;
}
.edge-gateway-logs__entry--expanded .edge-gateway-logs__summary {
white-space: normal;
}
.edge-gateway-logs__entry-details {
margin: 0;
padding: 0.65rem 0.75rem 0.75rem;
border-top: 1px solid #e2e8f0;
display: grid;
gap: 0.5rem;
}
.edge-gateway-logs__entry-details div {
display: grid;
grid-template-columns: minmax(5rem, 0.35fr) 1fr;
gap: 0.75rem;
}
.edge-gateway-logs__relay-details dt,
.edge-gateway-logs__relay-details dd {
.edge-gateway-logs__entry-details dt,
.edge-gateway-logs__entry-details dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
.edge-gateway-logs__relay-details dt {
.edge-gateway-logs__entry-details dt {
color: #64748b;
font-size: 0.78rem;
text-transform: uppercase;
}
.edge-gateway-logs__relay-details dd {
.edge-gateway-logs__entry-details dd {
color: #1f2937;
}
.edge-gateway-logs__entry-details pre {
max-height: 16rem;
margin: 0;
overflow: auto;
white-space: pre-wrap;
font: inherit;
}
.edge-gateway-logs__context-actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.edge-gateway-logs__context-modal {
position: fixed;
inset: 0;
z-index: 1000;
align-items: center;
justify-content: center;
}
.edge-gateway-logs__context-modal-card {
width: calc(100vw - 2rem);
max-width: none;
height: calc(100vh - 2rem);
max-height: none;
display: flex;
flex-direction: column;
}
.edge-gateway-logs__context-modal-body {
flex: 1 1 auto;
min-height: 0;
padding: 0;
}
.edge-gateway-logs__context-modal-body pre {
width: 100%;
height: 100%;
min-height: 0;
margin: 0;
padding: 1rem;
box-sizing: border-box;
overflow: auto;
background: #0f172a;
color: #e2e8f0;
font-family: Consolas, "Courier New", monospace;
font-size: 0.9rem;
line-height: 1.45;
white-space: pre;
}
.edge-gateway-logs__context-modal-footer {
justify-content: flex-end;
}
@media (max-width: 560px) {
.edge-gateway-logs__entry-details div {
grid-template-columns: 1fr;
gap: 0.2rem;
}
}
</style>
+80 -13
View File
@@ -55,6 +55,7 @@ const NumberPlateScanners = lazyView('@/views/dashboards/superUserDashboard/Numb
const Statistics = lazyView('@/views/dashboards/superUserDashboard/statistics/Statistics.vue');
const DepartmentSelfServe = lazyView('@/views/dashboards/superUserDashboard/DepartmentSelfServe.vue');
const SelfServeSessions = lazyView('@/views/dashboards/superUserDashboard/SelfServeSessions.vue');
const EdgeAgents = lazyView('@/views/dashboards/superUserDashboard/selfserve/EdgeAgents.vue');
const EdgeGatewaysWorkspacePage = lazyView('@/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue');
const Invoicing = lazyView('@/views/dashboards/superUserDashboard/Invoicing.vue');
@@ -651,6 +652,72 @@ export const router = createRouter({
component: SelfServeSessions,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagents',
path: '/superuser/selfserve/edge-agents',
component: EdgeAgents,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagentdetail',
path: '/superuser/selfserve/edge-agents/:id',
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/overview`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagentoverview',
path: '/superuser/selfserve/edge-agents/:id/overview',
component: EdgeAgents,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagenttasks',
path: '/superuser/selfserve/edge-agents/:id/tasks',
component: EdgeAgents,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagentlogs',
path: '/superuser/selfserve/edge-agents/:id/logs',
component: EdgeAgents,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagentstatistics',
path: '/superuser/selfserve/edge-agents/:id/statistics',
component: EdgeAgents,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagentterminal',
path: '/superuser/selfserve/edge-agents/:id/terminal',
component: EdgeAgents,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagentinventory',
path: '/superuser/selfserve/edge-agents/:id/inventory',
component: EdgeAgents,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagentoperations',
path: '/superuser/selfserve/edge-agents/:id/operations',
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/tasks`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagentsettings',
path: '/superuser/selfserve/edge-agents/:id/settings',
component: EdgeAgents,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgeagentmanage',
path: '/superuser/selfserve/edge-agents/:id/manage',
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/settings`,
meta: { middleware: superUserMiddleware }
},
{
name: 'departmentrelays',
path: '/superuser/department/relays',
@@ -666,73 +733,73 @@ export const router = createRouter({
{
name: 'edgegatewaydetail',
path: '/superuser/configuration/edgegateway/:id',
redirect: (to) => `/superuser/configuration/edgegateway/${encodeURIComponent(String(to.params.id))}/overview`,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/overview`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewayoverview',
path: '/superuser/configuration/edgegateway/:id/overview',
component: EdgeGatewaysWorkspacePage,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/overview`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewaytasks',
path: '/superuser/configuration/edgegateway/:id/tasks',
component: EdgeGatewaysWorkspacePage,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/tasks`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewaylogs',
path: '/superuser/configuration/edgegateway/:id/logs',
component: EdgeGatewaysWorkspacePage,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/logs`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewaystatistics',
path: '/superuser/configuration/edgegateway/:id/statistics',
component: EdgeGatewaysWorkspacePage,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/statistics`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewayterminal',
path: '/superuser/configuration/edgegateway/:id/terminal',
component: EdgeGatewaysWorkspacePage,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/terminal`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewayinventory',
path: '/superuser/configuration/edgegateway/:id/inventory',
component: EdgeGatewaysWorkspacePage,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/inventory`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewayoperations',
path: '/superuser/configuration/edgegateway/:id/operations',
redirect: (to) => `/superuser/configuration/edgegateway/${encodeURIComponent(String(to.params.id))}/tasks`,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/tasks`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewaysettings',
path: '/superuser/configuration/edgegateway/:id/settings',
component: EdgeGatewaysWorkspacePage,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/settings`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewaymanage',
path: '/superuser/configuration/edgegateway/:id/manage',
redirect: (to) => `/superuser/configuration/edgegateway/${encodeURIComponent(String(to.params.id))}/settings`,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/settings`,
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewayslegacy',
path: '/superuser/gateways',
redirect: '/superuser/configuration/edgegateway',
redirect: '/superuser/selfserve/edge-agents',
meta: { middleware: superUserMiddleware }
},
{
name: 'edgegatewaydetaillegacyroot',
path: '/superuser/gateways/:id',
redirect: (to) => `/superuser/configuration/edgegateway/${encodeURIComponent(String(to.params.id))}/overview`,
redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/overview`,
meta: { middleware: superUserMiddleware }
},
{
@@ -754,7 +821,7 @@ export const router = createRouter({
settings: 'settings',
};
const subPage = mapping[String(to.params.subPage || '').toLowerCase()] || 'overview';
return `/superuser/configuration/edgegateway/${encodeURIComponent(String(to.params.id))}/${subPage}`;
return `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/${subPage}`;
},
meta: { middleware: superUserMiddleware }
},
+3
View File
@@ -652,6 +652,9 @@ export const getEdgeGatewayModuleConfig = async (variable = null) => {
export const setEdgeGatewayModuleConfig = async (payload) =>
authenticatedRequest(EDGE_GATEWAY_CONFIG_BASE, "POST", payload);
export const testEdgeGatewayBrokerConfig = async (payload = {}) =>
authenticatedRequest(`${EDGE_GATEWAY_CONFIG_BASE}/broker-diagnostics`, "POST", payload);
export const EDGE_GATEWAY_OPERATIONS = {
discovery(request = {}) {
return {
@@ -8,7 +8,7 @@ import { useRouter } from "vue-router";
const router = useRouter();
const openFleetLanding = async () => {
await router.push("/superuser/configuration/edgegateway");
await router.push("/superuser/selfserve/edge-agents");
};
</script>
@@ -8,7 +8,7 @@ import { useRouter } from "vue-router";
const router = useRouter();
const openFleetLanding = async () => {
await router.push("/superuser/configuration/edgegateway");
await router.push("/superuser/selfserve/edge-agents");
};
</script>
@@ -9,7 +9,7 @@ import SelfServeSuperUserTabs from "@/views/dashboards/superUserDashboard/selfse
const router = useRouter();
const openFleetLanding = async () => {
await router.push("/superuser/configuration/edgegateway");
await router.push("/superuser/selfserve/edge-agents");
};
</script>
@@ -1,39 +1,11 @@
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { onMounted, reactive, ref } from "vue";
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
import EdgeGatewayFleetLanding from "@/features/edgeGateways/EdgeGatewayFleetLanding.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
import router from "@/router";
const currentRoute = computed(() => router.currentRoute.value || {});
const selectedGatewayId = computed(() => currentRoute.value.params?.id ?? null);
const activeView = computed(() => {
const routeName = currentRoute.value.name;
if (routeName === "edgegatewaytasks") {
return "tasks";
}
if (routeName === "edgegatewaylogs") {
return "logs";
}
if (routeName === "edgegatewaystatistics") {
return "statistics";
}
if (routeName === "edgegatewayterminal") {
return "terminal";
}
if (routeName === "edgegatewayinventory") {
return "inventory";
}
if (routeName === "edgegatewaysettings") {
return "settings";
}
return "overview";
});
const moduleConfigEntries = ref([]);
const moduleConfigMap = ref({
@@ -51,18 +23,16 @@ const moduleState = reactive({
unavailable: false,
error: null,
});
const brokerDiagnostics = reactive({
testing: null,
results: {},
error: null,
});
const canonicalPath = (gatewayId, view = "overview") =>
gatewayId
? `/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/${view}`
: "/superuser/configuration/edgegateway";
const handleNavigation = async ({ gatewayId, view }) => {
const nextPath = canonicalPath(gatewayId, view || "overview");
if (window.location.pathname === nextPath) {
return;
}
await router.push(nextPath);
const brokerDiagnosticKeys = {
internal: "internal_broker_connection",
public: "public_broker_url",
secret: "broker_shared_secret",
};
const loadModuleConfig = async () => {
@@ -112,6 +82,64 @@ const saveModuleConfig = async () => {
}
};
const brokerDiagnosticPayload = (target) => ({
target,
broker_url: moduleConfigMap.value.broker_url || "",
public_broker_url: moduleConfigMap.value.public_broker_url || "",
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
});
const testBrokerConfig = async (target) => {
const resultKey = brokerDiagnosticKeys[target];
if (!resultKey) {
return;
}
brokerDiagnostics.testing = target;
brokerDiagnostics.error = null;
try {
const response = await SessionUser.superUser.modules.edgegateway.config.test_broker(
brokerDiagnosticPayload(target)
);
const diagnostics = response?.data?.data || {};
brokerDiagnostics.results = {
...brokerDiagnostics.results,
[resultKey]: diagnostics[resultKey] || null,
};
} catch (error) {
const normalized = normalizeEdgeGatewayError(error);
brokerDiagnostics.error = normalized;
brokerDiagnostics.results = {
...brokerDiagnostics.results,
[resultKey]: {
ok: false,
status: "request_failed",
message: normalized.message,
},
};
} finally {
brokerDiagnostics.testing = null;
}
};
const brokerDiagnosticResult = (target) =>
brokerDiagnostics.results[brokerDiagnosticKeys[target]] || null;
const brokerDiagnosticState = (target) => {
const result = brokerDiagnosticResult(target);
if (!result) {
return "idle";
}
return result.ok ? "success" : "danger";
};
const brokerDiagnosticMessage = (target) => {
const result = brokerDiagnosticResult(target);
return result?.message || "";
};
onMounted(async () => {
await loadModuleConfig();
});
@@ -123,7 +151,7 @@ onMounted(async () => {
<template #title>
<PageTitle
title="Edge gateway module"
subtitle="Module defaults, fleet state, rollout visibility, and gateway operations for the edge runtime"
subtitle="Module defaults and broker settings for the edge runtime"
/>
</template>
@@ -144,7 +172,7 @@ onMounted(async () => {
</div>
<div v-if="moduleState.unavailable" class="edge-gateway-module-page__disabled" data-testid="gateway-module-disabled-state">
The edge gateway module routes are currently unavailable. Re-enable the module before managing fleet traffic here.
The edge gateway module routes are currently unavailable. Re-enable the module before saving edge runtime defaults here.
</div>
<div v-else class="edge-gateway-module-page__grid">
@@ -182,25 +210,65 @@ onMounted(async () => {
<p>Connection values used by the PHP manager when it prepares browser and gateway broker traffic.</p>
</div>
<label class="edge-gateway-module-page__field">
<div class="edge-gateway-module-page__field">
<span>Internal broker URL</span>
<input
v-model="moduleConfigMap.broker_url"
class="input"
data-testid="gateway-module-broker-url"
placeholder="http://edge-broker:4300"
/>
</label>
<div class="edge-gateway-module-page__field-control">
<input
v-model="moduleConfigMap.broker_url"
class="input"
data-testid="gateway-module-broker-url"
placeholder="http://edge-broker:4300"
/>
<button
class="button is-light"
type="button"
data-testid="gateway-module-test-internal-broker"
:class="{ 'is-loading': brokerDiagnostics.testing === 'internal' }"
:disabled="moduleState.loading || moduleState.saving || Boolean(brokerDiagnostics.testing)"
@click="testBrokerConfig('internal')"
>
Test
</button>
</div>
<p
v-if="brokerDiagnosticResult('internal')"
class="edge-gateway-module-page__diagnostic"
data-testid="gateway-module-broker-diagnostic-internal"
:data-state="brokerDiagnosticState('internal')"
>
{{ brokerDiagnosticMessage("internal") }}
</p>
</div>
<label class="edge-gateway-module-page__field">
<div class="edge-gateway-module-page__field">
<span>Public broker URL</span>
<input
v-model="moduleConfigMap.public_broker_url"
class="input"
data-testid="gateway-module-public-broker-url"
placeholder="https://api.truckwash.io:4433/edge-broker"
/>
</label>
<div class="edge-gateway-module-page__field-control">
<input
v-model="moduleConfigMap.public_broker_url"
class="input"
data-testid="gateway-module-public-broker-url"
placeholder="https://api.truckwash.io:4433/edge-broker"
/>
<button
class="button is-light"
type="button"
data-testid="gateway-module-test-public-broker"
:class="{ 'is-loading': brokerDiagnostics.testing === 'public' }"
:disabled="moduleState.loading || moduleState.saving || Boolean(brokerDiagnostics.testing)"
@click="testBrokerConfig('public')"
>
Test
</button>
</div>
<p
v-if="brokerDiagnosticResult('public')"
class="edge-gateway-module-page__diagnostic"
data-testid="gateway-module-broker-diagnostic-public"
:data-state="brokerDiagnosticState('public')"
>
{{ brokerDiagnosticMessage("public") }}
</p>
</div>
<label class="edge-gateway-module-page__field">
<span>Broker auth mode</span>
@@ -212,17 +280,37 @@ onMounted(async () => {
</div>
</label>
<label class="edge-gateway-module-page__field">
<div class="edge-gateway-module-page__field">
<span>Broker shared secret</span>
<input
v-model="moduleConfigMap.broker_shared_secret"
autocomplete="off"
class="input"
data-testid="gateway-module-broker-shared-secret"
placeholder="Shared secret"
type="password"
/>
</label>
<div class="edge-gateway-module-page__field-control">
<input
v-model="moduleConfigMap.broker_shared_secret"
autocomplete="off"
class="input"
data-testid="gateway-module-broker-shared-secret"
placeholder="Shared secret"
type="password"
/>
<button
class="button is-light"
type="button"
data-testid="gateway-module-test-broker-secret"
:class="{ 'is-loading': brokerDiagnostics.testing === 'secret' }"
:disabled="moduleState.loading || moduleState.saving || Boolean(brokerDiagnostics.testing)"
@click="testBrokerConfig('secret')"
>
Validate
</button>
</div>
<p
v-if="brokerDiagnosticResult('secret')"
class="edge-gateway-module-page__diagnostic"
data-testid="gateway-module-broker-diagnostic-secret"
:data-state="brokerDiagnosticState('secret')"
>
{{ brokerDiagnosticMessage("secret") }}
</p>
</div>
</div>
<div class="edge-gateway-module-page__actions">
@@ -237,17 +325,6 @@ onMounted(async () => {
</button>
</div>
</article>
<EdgeGatewayFleetLanding v-if="!moduleState.unavailable && !selectedGatewayId" />
<EdgeGatewayManager
v-if="!moduleState.unavailable"
:selected-gateway-id="selectedGatewayId"
:active-view="activeView"
:route-driven="true"
:allow-destructive="true"
@navigate="handleNavigation"
/>
</section>
</template>
</ConfigurationSubPageWrapper>
@@ -299,6 +376,41 @@ onMounted(async () => {
color: #334155;
}
.edge-gateway-module-page__field-control {
display: flex;
gap: 0.5rem;
align-items: stretch;
min-width: 0;
}
.edge-gateway-module-page__field-control .input,
.edge-gateway-module-page__field-control .select {
min-width: 0;
flex: 1 1 auto;
}
.edge-gateway-module-page__field-control .button {
flex: 0 0 auto;
min-width: 5.75rem;
white-space: nowrap;
}
.edge-gateway-module-page__diagnostic {
margin: 0;
min-height: 1.15rem;
font-size: 0.82rem;
line-height: 1.35;
color: #475569;
}
.edge-gateway-module-page__diagnostic[data-state="success"] {
color: #166534;
}
.edge-gateway-module-page__diagnostic[data-state="danger"] {
color: #b91c1c;
}
.edge-gateway-module-page__section-title {
grid-column: 1 / -1;
margin-top: 0.5rem;
@@ -329,4 +441,14 @@ onMounted(async () => {
border: 1px solid #fdba74;
color: #9a3412;
}
@media (max-width: 680px) {
.edge-gateway-module-page__field-control {
flex-direction: column;
}
.edge-gateway-module-page__field-control .button {
width: 100%;
}
}
</style>
@@ -8,7 +8,7 @@ import { useRouter } from "vue-router";
const router = useRouter();
const openFleetLanding = async () => {
await router.push("/superuser/configuration/edgegateway");
await router.push("/superuser/selfserve/edge-agents");
};
</script>
@@ -128,7 +128,7 @@ const testConnection = async () => {
>
<p><strong>{{ gatewaySummary.total }}</strong> registered gateway(s), {{ gatewaySummary.online }} online, {{ gatewaySummary.degraded }} degraded, {{ gatewaySummary.offline }} offline.</p>
<div class="buttons mt-3">
<button class="button is-link" @click="router.push('/superuser/configuration/edgegateway')">Open fleet manager</button>
<button class="button is-link" @click="router.push('/superuser/selfserve/edge-agents')">Open fleet manager</button>
<button class="button is-light" @click="load">Refresh gateway health</button>
</div>
</ConfigurationCategory>
@@ -0,0 +1,77 @@
<script setup>
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import EdgeGatewayFleetLanding from "@/features/edgeGateways/EdgeGatewayFleetLanding.vue";
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import SelfServeSuperUserTabs from "@/views/dashboards/superUserDashboard/selfserve/SelfServeSuperUserTabs.vue";
const route = useRoute();
const router = useRouter();
const selectedGatewayId = computed(() => route.params?.id ?? null);
const activeView = computed(() => {
const routeName = String(route.name || "");
if (routeName === "edgeagenttasks") {
return "tasks";
}
if (routeName === "edgeagentlogs") {
return "logs";
}
if (routeName === "edgeagentstatistics") {
return "statistics";
}
if (routeName === "edgeagentterminal") {
return "terminal";
}
if (routeName === "edgeagentinventory") {
return "inventory";
}
if (routeName === "edgeagentsettings") {
return "settings";
}
return "overview";
});
const canonicalPath = (gatewayId, view = "overview") =>
gatewayId
? `/superuser/selfserve/edge-agents/${encodeURIComponent(String(gatewayId))}/${view}`
: "/superuser/selfserve/edge-agents";
const handleNavigation = async ({ gatewayId, view }) => {
const nextPath = canonicalPath(gatewayId, view || "overview");
if (window.location.pathname === nextPath) {
return;
}
await router.push(nextPath);
};
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<SuperUserDashboardNavigation />
<SelfServeSuperUserTabs />
<main class="edge-agents-page" data-testid="edge-agents-page">
<EdgeGatewayFleetLanding v-if="!selectedGatewayId" />
<EdgeGatewayManager
:selected-gateway-id="selectedGatewayId"
:active-view="activeView"
:route-driven="true"
:allow-destructive="true"
@navigate="handleNavigation"
/>
</main>
</RestrictedPageWrapper>
</template>
<style scoped>
.edge-agents-page {
display: grid;
gap: 1rem;
}
</style>
@@ -6,6 +6,7 @@ const route = useRoute();
const tabs = [
{ label: "Lanes", to: "/superuser/selfserve", exact: true },
{ label: "Sessions", to: "/superuser/selfserve/sessions", exact: false },
{ label: "Edge Agents", to: "/superuser/selfserve/edge-agents", exact: false },
];
const isActive = (tab: { to: string; exact: boolean }): boolean => (
+43 -12
View File
@@ -14,41 +14,42 @@ test.describe("Edge gateway routing and fleet navigation", () => {
await primeSuperuserSession(page);
});
test("redirects canonical detail routes onto overview", async ({ page }) => {
test("redirects legacy configuration detail routes onto the Edge Agents overview", async ({ page }) => {
await page.goto("/superuser/configuration/edgegateway/701");
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway\/701\/overview$/);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/overview$/);
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
await expect(page.getByTestId("gateway-tab-nav")).toBeVisible();
});
test("redirects legacy sub-page URLs onto the mapped module route", async ({ page }) => {
test("redirects legacy sub-page URLs onto the mapped Edge Agents route", async ({ page }) => {
await page.goto("/superuser/gateways/701/configure");
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway\/701\/inventory$/);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/inventory$/);
await expect(page.getByTestId("gateway-inventory-page")).toBeVisible();
});
test("redirects legacy operations and manage routes onto tasks and settings", async ({ page }) => {
await page.goto("/superuser/configuration/edgegateway/701/operations");
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway\/701\/tasks$/);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/tasks$/);
await expect(page.getByTestId("gateway-tasks-page")).toBeVisible();
await page.goto("/superuser/gateways/701/manage");
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway\/701\/settings$/);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/settings$/);
await expect(page.getByTestId("gateway-settings-page")).toBeVisible();
});
test("shows the unavailable state for unknown detail routes", async ({ page }) => {
await page.goto("/superuser/configuration/edgegateway/999999/overview");
await page.goto("/superuser/selfserve/edge-agents/999999/overview");
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway\/999999\/overview$/);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/999999\/overview$/);
await expect(page.getByTestId("gateway-unavailable-state")).toContainText("999999");
});
test("filters the fleet roster with search and summary chips", async ({ page }) => {
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByRole("link", { name: "Edge Agents" })).toHaveClass(/selfserve-tabs__link--active/);
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
await page.getByTestId("gateway-fleet-search").fill("ode");
await expect(page.getByTestId("gateway-fleet-item-702")).toBeVisible();
@@ -79,8 +80,38 @@ test.describe("Edge gateway routing and fleet navigation", () => {
await expect(page.getByTestId("gateway-module-broker-shared-secret")).toHaveValue("updated-broker-secret");
});
test("tests broker module configuration values", async ({ page }) => {
await page.goto("/superuser/configuration/edgegateway", { waitUntil: "domcontentloaded" });
await page.getByTestId("gateway-module-test-internal-broker").click();
await expect(page.getByTestId("gateway-module-broker-diagnostic-internal")).toContainText(
"Internal broker responded"
);
await expect(page.getByTestId("gateway-module-broker-diagnostic-internal")).toHaveAttribute(
"data-state",
"success"
);
await page.getByTestId("gateway-module-test-public-broker").click();
await expect(page.getByTestId("gateway-module-broker-diagnostic-public")).toContainText("Public broker responded");
await expect(page.getByTestId("gateway-module-broker-diagnostic-public")).toHaveAttribute("data-state", "success");
await page.getByTestId("gateway-module-test-broker-secret").click();
await expect(page.getByTestId("gateway-module-broker-diagnostic-secret")).toContainText(
"Broker accepted the configured shared secret"
);
await expect(page.getByTestId("gateway-module-broker-diagnostic-secret")).toHaveAttribute("data-state", "success");
await page.getByTestId("gateway-module-broker-shared-secret").fill("wrong-secret");
await page.getByTestId("gateway-module-test-broker-secret").click();
await expect(page.getByTestId("gateway-module-broker-diagnostic-secret")).toContainText(
"Broker rejected the configured shared secret"
);
await expect(page.getByTestId("gateway-module-broker-diagnostic-secret")).toHaveAttribute("data-state", "danger");
});
test("opens a department workspace from the fleet landing", async ({ page }) => {
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
await expect(page.getByTestId("hardware-fleet-department-1")).toContainText("Copenhagen");
@@ -109,7 +140,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
await page.getByTestId("gateway-open-full-page").click();
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway\/701\/overview$/);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/overview$/);
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
});
});
@@ -121,7 +152,7 @@ test("shows colored issues with suggest-fix actions in the department workspace"
});
await seedAuthenticatedState(page, "superuser-edge-gateway-routes-token");
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await page.getByTestId("hardware-fleet-open-workspace-1").click();
await expect(page.getByTestId("department-hardware-workspace")).toBeVisible();
+58 -23
View File
@@ -65,7 +65,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("gateway-error-banner")).toContainText("Column not found");
await page.getByTestId("gateway-error-details").click();
@@ -114,7 +114,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Copy Test Pi");
@@ -137,7 +137,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("gateway-fleet-usage")).toBeVisible();
await expect(page.getByTestId("gateway-fleet-usage-coverage")).toContainText("2 gateways");
@@ -163,7 +163,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
await expect(page.getByTestId("gateway-onboarding")).toContainText("Install new gateway");
@@ -178,7 +178,7 @@ test.describe("Edge gateway management smoke", () => {
await expect
.poll(async () => (await page.getByTestId("gateway-installer-status-step").textContent())?.trim())
.not.toBe("Waiting to start");
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway\/703\/overview$/, { timeout: 20_000 });
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/703\/overview$/, { timeout: 20_000 });
await expect(page.getByTestId("gateway-detail-header")).toContainText("Canary Pi");
await expect(page.locator("body")).toContainText("Gateway connected.");
});
@@ -194,7 +194,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
await page.getByTestId("gateway-installer-department").selectOption("1");
@@ -203,7 +203,7 @@ test.describe("Edge gateway management smoke", () => {
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running");
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway\/701\/overview$/, { timeout: 20_000 });
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/overview$/, { timeout: 20_000 });
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
await expect(page.locator("body")).toContainText("Gateway reconnected.");
});
@@ -239,7 +239,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Broken Pi");
@@ -258,7 +258,7 @@ test.describe("Edge gateway management smoke", () => {
await expect(page.getByTestId("gateway-installer-status-diagnostics")).toContainText(
"Compose rollout failed during build/startup"
);
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway(?:\?.*)?$/);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents(?:\?.*)?$/);
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
await expect(page.getByTestId("gateway-onboarding")).toContainText("Install new gateway");
await expect(page.getByTestId("gateway-fleet-usage-coverage")).toContainText("0 gateways");
@@ -271,7 +271,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/overview");
await page.goto("/superuser/selfserve/edge-agents/701/overview");
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
await page.getByTestId("gateway-tab-settings").click();
@@ -302,7 +302,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
await page.getByTestId("gateway-installer-department").selectOption("1");
@@ -310,7 +310,7 @@ test.describe("Edge gateway management smoke", () => {
await page.getByTestId("gateway-installer-generate").click();
await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/);
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway\/703\/overview$/, { timeout: 20_000 });
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/703\/overview$/, { timeout: 20_000 });
await expect(page.getByTestId("gateway-detail-header")).toContainText("Canary Pi");
await expect(page.locator("body")).toContainText("Gateway connected.");
});
@@ -337,7 +337,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/overview");
await page.goto("/superuser/selfserve/edge-agents/701/overview");
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
await page.getByTestId("gateway-tab-statistics").click();
@@ -455,7 +455,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/overview");
await page.goto("/superuser/selfserve/edge-agents/701/overview");
await expect(page.getByTestId("gateway-overview-container-health")).toContainText("ONLINE");
await expect(page.getByTestId("gateway-overview-container-health")).toContainText("6/6 containers healthy");
@@ -466,13 +466,25 @@ test.describe("Edge gateway management smoke", () => {
});
test("@smoke manages discovery, bindings, tasks, logs, statistics, uninstall, and delete", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: async (text) => {
window.__copiedGatewayContext = text;
},
},
});
window.__copiedGatewayContext = "";
});
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/inventory");
await page.goto("/superuser/selfserve/edge-agents/701/inventory");
await expect(page.getByTestId("gateway-binding-card-0")).toContainText("Roskilde machine north", {
timeout: 15_000,
});
@@ -501,11 +513,34 @@ test.describe("Edge gateway management smoke", () => {
await page.getByTestId("gateway-tab-logs").click();
await expect(page.getByTestId("gateway-logs-page")).toBeVisible();
await expect(page.getByTestId("gateway-logs-timeline")).toContainText("GATEWAY_OPERATION_QUEUED");
await expect(page.getByTestId("gateway-relay-logs")).toContainText("Relay SWITCH M-7 handled by local");
await expect(page.getByTestId("gateway-relay-logs")).toContainText("MACHINE ON Roskilde Maskine handled by local");
await expect(page.getByTestId("gateway-relay-logs")).toContainText("Open ENTRY Roskilde indkoerselspc");
await expect(page.getByTestId("gateway-relay-logs")).toContainText("selfserve");
await expect(page.getByTestId("gateway-log-entry-details-0")).toHaveCount(0);
await page.getByTestId("gateway-log-entry-0").click();
await expect(page.getByTestId("gateway-log-entry-details-0")).toContainText("Timestamp");
await expect(page.getByTestId("gateway-log-entry-details-0")).toContainText("Level");
await expect(page.getByTestId("gateway-relay-entry-details-0")).toHaveCount(0);
await expect(page.getByTestId("gateway-relay-logs")).not.toContainText("Customer 700123");
await page.getByTestId("gateway-relay-entry-0").click();
await expect(page.getByTestId("gateway-relay-entry-details-0")).toContainText("Customer 700123");
await expect(page.getByTestId("gateway-relay-entry-details-0")).toContainText("Context");
await expect(page.getByTestId("gateway-relay-entry-details-0")).not.toContainText("SET_RELAY_STATE");
await expect(page.getByTestId("gateway-relay-entry-details-0")).not.toContainText("module_responsible");
await page.getByTestId("gateway-relay-entry-context-copy-0").click();
await expect(page.getByTestId("gateway-relay-entry-context-copy-0")).toContainText("Copied");
expect(await page.evaluate(() => window.__copiedGatewayContext)).toContain('"module_responsible"');
await page.getByTestId("gateway-relay-entry-context-view-0").click();
await expect(page.getByTestId("gateway-context-modal")).toBeVisible();
await expect(page.getByTestId("gateway-context-modal-body")).toContainText("SET_RELAY_STATE");
await expect(page.getByTestId("gateway-context-modal-body")).toContainText("module_responsible");
await page.getByTestId("gateway-context-modal-close").click();
await expect(page.getByTestId("gateway-context-modal")).toHaveCount(0);
await expect(page.getByTestId("gateway-relay-logs")).toContainText("Customer 700123");
await page.getByTestId("gateway-logs-type-filter").selectOption("relay");
await expect(page.getByTestId("gateway-logs-timeline")).toContainText("Relay SWITCH M-7 handled by local");
await expect(page.getByTestId("gateway-logs-timeline")).toContainText(
"MACHINE ON Roskilde Maskine handled by local"
);
await page.getByTestId("gateway-tab-statistics").click();
await expect(page.getByTestId("gateway-statistics-page")).toBeVisible();
@@ -529,7 +564,7 @@ test.describe("Edge gateway management smoke", () => {
await page.getByTestId("gateway-delete-confirmation").fill("CPH Edge 01");
await page.getByTestId("gateway-delete").click();
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway$/, { timeout: 8_000 });
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents$/, { timeout: 8_000 });
await expect(page.locator("body")).toContainText(/(Gateway deleted\.|Gateway slettet\.)/);
});
@@ -992,7 +1027,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/tasks");
await page.goto("/superuser/selfserve/edge-agents/701/tasks");
await page.getByTestId("gateway-operation-discovery").click();
await expect(page.getByTestId("gateway-operation-cancel")).toBeVisible();
@@ -1035,7 +1070,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/tasks");
await page.goto("/superuser/selfserve/edge-agents/701/tasks");
await expect(page.getByTestId("gateway-recent-commands")).toContainText("GET_RELAY_STATUS");
await expect(page.getByTestId("gateway-recent-commands")).toContainText("Timed out");
@@ -1069,7 +1104,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/terminal", { waitUntil: "domcontentloaded" });
await page.goto("/superuser/selfserve/edge-agents/701/terminal", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/not connected to the broker/i);
@@ -1098,7 +1133,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/terminal", { waitUntil: "domcontentloaded" });
await page.goto("/superuser/selfserve/edge-agents/701/terminal", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/Broker did not complete/i, {
@@ -1119,7 +1154,7 @@ test.describe("Edge gateway management smoke", () => {
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/terminal", { waitUntil: "domcontentloaded" });
await page.goto("/superuser/selfserve/edge-agents/701/terminal", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/connecting|open/i);
+6 -6
View File
@@ -17,7 +17,7 @@ test.describe("Edge gateway visuals", () => {
test("desktop roster and module overview render together", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
await page.goto("/superuser/configuration/edgegateway/701/overview");
await page.goto("/superuser/selfserve/edge-agents/701/overview");
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
await expect(page.getByTestId("gateway-fleet-roster")).toBeVisible();
@@ -26,12 +26,12 @@ test.describe("Edge gateway visuals", () => {
await expect(page.getByTestId("gateway-diagnostics-list")).toBeVisible();
});
test("mobile module landing exposes onboarding and fleet controls", async ({ page }, testInfo) => {
test("mobile Edge Agents landing exposes onboarding and fleet controls", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("mobile"), "Mobile only");
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible();
await expect(page.getByTestId("edge-agents-page")).toBeVisible();
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
await expect(page.getByTestId("gateway-fleet-roster")).toBeVisible();
@@ -42,7 +42,7 @@ test.describe("Edge gateway visuals", () => {
test("desktop hardware landing and navigation remain visually distinct", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "Chromium desktop snapshot");
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
await expect(page.getByTestId("desktop-buefy-navigation")).toBeVisible();
@@ -51,7 +51,7 @@ test.describe("Edge gateway visuals", () => {
"edge-gateways-hardware-fleet-landing-desktop-win32.png"
);
await expect(page.getByTestId("desktop-buefy-navigation")).toHaveScreenshot(
"edge-gateways-configuration-navigation-desktop-win32.png"
"edge-agents-navigation-desktop-win32.png"
);
});
});
@@ -34,7 +34,7 @@ test.describe("Edge gateway live smoke gate", () => {
await loginAsOperator(page, superuserCredentials);
await page.goto("/superuser/configuration/edgegateway");
await page.goto("/superuser/selfserve/edge-agents");
await settlePage(page);
await expect(page).toHaveURL(/\/superuser\/configuration\/edgegateway(?:\?.*)?$/);
await expectBodyHasContent(page);
@@ -42,9 +42,9 @@ test.describe("Edge gateway live smoke gate", () => {
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
await expect(page.locator("body")).not.toContainText(/404/i);
await page.goto(`/superuser/configuration/edgegateway/${gatewayId}/overview`);
await page.goto(`/superuser/selfserve/edge-agents/${gatewayId}/overview`);
await settlePage(page);
await expect(page).toHaveURL(new RegExp(`/superuser/configuration/edgegateway/${gatewayId}/overview(?:\\?.*)?$`));
await expect(page).toHaveURL(new RegExp(`/superuser/selfserve/edge-agents/${gatewayId}/overview(?:\\?.*)?$`));
await expect(page.getByTestId("gateway-detail-header")).toBeVisible();
await expect(page.locator("body")).not.toContainText(/404/i);
@@ -492,7 +492,7 @@ test.describe("Superuser system status smoke", () => {
await expect(gatewayCards.nth(3)).toContainText("Udruller opdatering");
await expect(gatewayCards.nth(4)).toContainText("Discovery har ikke rapporteret i 30 minutter.");
await expect(gatewayCards.nth(6)).toContainText("gw-307.truckwash.test");
await expect(page.locator('a[href="/superuser/configuration/edgegateway/301/overview"]')).toBeVisible();
await expect(page.locator('a[href="/superuser/selfserve/edge-agents/301/overview"]')).toBeVisible();
await expect(page.locator('[data-testid="gateway-card-309"]')).toHaveCount(0);
});
});
+91 -2
View File
@@ -1896,7 +1896,7 @@ function createHttpEdgeGatewayFixture(options = {}) {
level: "INFO",
stream: "relay",
source: "RELAY_DISPATCH",
message: "Relay SWITCH M-7 handled by local via BROKER_FAST_PATH",
message: "MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH",
context: {
module: "selfserve",
module_responsible: "selfserve",
@@ -1904,6 +1904,9 @@ function createHttpEdgeGatewayFixture(options = {}) {
handler: "local",
delivery_channel: "BROKER_FAST_PATH",
relay_id: "M-7",
relay_name: "Roskilde Maskine",
relay_role: "MACHINE",
description: "MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH",
action: "SWITCH",
target_on: true,
associated: {
@@ -1924,6 +1927,38 @@ function createHttpEdgeGatewayFixture(options = {}) {
},
},
},
{
id: 804,
created_at: "2026-04-08 08:14:56",
level: "INFO",
stream: "relay",
source: "RELAY_DISPATCH",
message: "Relay SWITCH IN-7 handled by local via BROKER_FAST_PATH",
context: {
module: "selfserve",
module_responsible: "selfserve",
reason: "Open self-serve ENTRY gate relay",
handler: "local",
delivery_channel: "BROKER_FAST_PATH",
relay_id: "IN-7",
relay_name: "Roskilde indkoerselspc",
relay_role: "ENTRY",
action: "SWITCH",
target_on: true,
signal: {
command_type: "SET_RELAY_STATE",
relay_id: "IN-7",
request: {
relayId: "IN-7",
on: true,
},
},
response: {
online: true,
on: true,
},
},
},
],
operations: [
createFixtureOperation(
@@ -4037,7 +4072,7 @@ function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departm
actions.push({
code: "INSTALL_GATEWAY",
label: "Install first edge gateway",
path: "/superuser/configuration/edgegateway",
path: "/superuser/selfserve/edge-agents",
});
}
@@ -4270,6 +4305,60 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
return true;
}
if (pathname.endsWith("/edgegateway/config/broker-diagnostics") && method === "POST") {
const body = request.postDataJSON?.() || {};
const target = String(body.target || "all");
const payload = {
target,
checked_at: "2026-04-28 10:00:00",
broker_auth_mode: String(body.broker_auth_mode || edgeGatewayFixture.config.broker_auth_mode || "manager"),
broker_shared_secret_configured: Boolean(
body.broker_shared_secret || edgeGatewayFixture.config.broker_shared_secret
),
};
if (target === "internal" || target === "all") {
payload.internal_broker_connection = {
ok: Boolean(body.broker_url),
status: body.broker_url ? "connected" : "not_configured",
status_code: body.broker_url ? 200 : null,
url: body.broker_url || null,
message: body.broker_url
? "Internal broker responded to the health check."
: "Internal broker URL is not configured.",
};
}
if (target === "public" || target === "all") {
payload.public_broker_url = {
ok: Boolean(body.public_broker_url),
status: body.public_broker_url ? "connected" : "not_configured",
status_code: body.public_broker_url ? 200 : null,
url: body.public_broker_url || null,
message: body.public_broker_url
? "Public broker responded to the health check."
: "Public broker URL is not configured.",
};
}
if (target === "secret" || target === "all") {
const validSecret =
String(body.broker_shared_secret || "") === String(edgeGatewayFixture.config.broker_shared_secret || "");
payload.broker_shared_secret = {
ok: validSecret,
status: validSecret ? "validated" : "secret_rejected",
status_code: validSecret ? 200 : 403,
url: body.broker_url || edgeGatewayFixture.config.broker_url || null,
message: validSecret
? "Broker accepted the configured shared secret."
: "Broker rejected the configured shared secret.",
};
}
await route.fulfill(json({ data: payload }));
return true;
}
if (edgeGatewayWorkspaceDepartmentsPattern.test(pathname) && method === "GET") {
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
const summaries = (hardware?.departments || [])
@@ -929,7 +929,7 @@ describe("ActionSettingsWheelButton", () => {
expect(SessionUser.request).toHaveBeenCalledWith("/modules/edge-gateways/workspace/departments/1", "GET");
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith(
"/configuration/edgegateway/701/overview",
"/selfserve/edge-agents/701/overview",
true
);
+47 -23
View File
@@ -17,6 +17,14 @@ const edgeGatewaysPageSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue"),
"utf8"
);
const edgeAgentsSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/selfserve/EdgeAgents.vue"),
"utf8"
);
const selfServeTabsSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/selfserve/SelfServeSuperUserTabs.vue"),
"utf8"
);
const departmentWorkspaceSource = readFileSync(
join(root, "src/features/edgeGateways/EdgeGatewayDepartmentWorkspace.vue"),
"utf8"
@@ -71,6 +79,8 @@ describe("edge gateway workspace contract", () => {
expect(logsSource).toContain('data-testid="gateway-logs-page"');
expect(logsSource).toContain('data-testid="gateway-relay-logs"');
expect(logsSource).toContain('<option value="relay">Relay</option>');
expect(logsSource).toContain("const relayDescription = (entry = {}) =>");
expect(logsSource).toContain('MACHINE_PROGRAM_PICKER: "PROGRAM_PICKER"');
expect(managerSource).toContain(':relay-logs="logRelayLogs"');
expect(statisticsSource).toContain('data-testid="gateway-statistics-page"');
expect(terminalSource).toContain('data-testid="gateway-terminal-page"');
@@ -118,13 +128,19 @@ describe("edge gateway workspace contract", () => {
expect(managerSource).toContain(':stream-status="gatewayStreamStatus"');
});
it("mounts a module workspace and a safe-subset department workspace", () => {
it("keeps module configuration separate from the Edge Agents fleet workspace", () => {
expect(edgeGatewaysPageSource).toContain('data-testid="edge-gateway-module-config"');
expect(edgeGatewaysPageSource).toContain("gateway-module-disabled-state");
expect(edgeGatewaysPageSource).toContain(':route-driven="true"');
expect(edgeGatewaysPageSource).toContain(':allow-destructive="true"');
expect(edgeGatewaysPageSource).toContain('import router from "@/router";');
expect(edgeGatewaysPageSource).toContain("router.currentRoute.value");
expect(edgeGatewaysPageSource).not.toContain("EdgeGatewayManager");
expect(edgeGatewaysPageSource).not.toContain("EdgeGatewayFleetLanding");
expect(edgeAgentsSource).toContain('data-testid="edge-agents-page"');
expect(edgeAgentsSource).toContain("EdgeGatewayFleetLanding");
expect(edgeAgentsSource).toContain("EdgeGatewayManager");
expect(edgeAgentsSource).toContain(':route-driven="true"');
expect(edgeAgentsSource).toContain(':allow-destructive="true"');
expect(edgeAgentsSource).toContain("/superuser/selfserve/edge-agents");
expect(selfServeTabsSource).toContain("Edge Agents");
expect(selfServeTabsSource).toContain("/superuser/selfserve/edge-agents");
expect(departmentGatewaysPageSource).toContain("EdgeGatewayDepartmentWorkspace");
expect(departmentWorkspaceSource).toContain(':route-driven="false"');
expect(departmentWorkspaceSource).toContain(':allow-destructive="false"');
@@ -133,32 +149,40 @@ describe("edge gateway workspace contract", () => {
it("registers canonical module routes and keeps legacy redirects", () => {
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway'");
expect(routerSource).toContain("name: 'edgegatewayoverview'");
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/overview'");
expect(routerSource).toContain("name: 'edgegatewaytasks'");
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/tasks'");
expect(routerSource).toContain("name: 'edgegatewaylogs'");
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/logs'");
expect(routerSource).toContain("name: 'edgegatewaystatistics'");
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/statistics'");
expect(routerSource).toContain("name: 'edgegatewayterminal'");
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/terminal'");
expect(routerSource).toContain("name: 'edgegatewayinventory'");
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/inventory'");
expect(routerSource).toContain("name: 'edgegatewayoperations'");
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/operations'");
expect(routerSource).toContain("component: EdgeGatewaysWorkspacePage");
expect(routerSource).toContain("name: 'edgeagents'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents'");
expect(routerSource).toContain("name: 'edgeagentoverview'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents/:id/overview'");
expect(routerSource).toContain("name: 'edgeagenttasks'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents/:id/tasks'");
expect(routerSource).toContain("name: 'edgeagentlogs'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents/:id/logs'");
expect(routerSource).toContain("name: 'edgeagentstatistics'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents/:id/statistics'");
expect(routerSource).toContain("name: 'edgeagentterminal'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents/:id/terminal'");
expect(routerSource).toContain("name: 'edgeagentinventory'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents/:id/inventory'");
expect(routerSource).toContain("name: 'edgeagentoperations'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents/:id/operations'");
expect(routerSource).toContain("/tasks`");
expect(routerSource).toContain("name: 'edgegatewaysettings'");
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/settings'");
expect(routerSource).toContain("name: 'edgegatewaymanage'");
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/manage'");
expect(routerSource).toContain("name: 'edgeagentsettings'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents/:id/settings'");
expect(routerSource).toContain("name: 'edgeagentmanage'");
expect(routerSource).toContain("path: '/superuser/selfserve/edge-agents/:id/manage'");
expect(routerSource).toContain("/settings`");
expect(routerSource).toContain("name: 'edgegatewayoverview'");
expect(routerSource).toContain(
"redirect: (to) => `/superuser/selfserve/edge-agents/${encodeURIComponent(String(to.params.id))}/overview`"
);
expect(routerSource).toContain("path: '/superuser/gateways'");
expect(routerSource).toContain("configure: 'inventory'");
expect(routerSource).toContain("assess: 'overview'");
});
it("surfaces the module in superuser navigation and configuration tabs", () => {
expect(navigationSource).toContain("/superuser/selfserve/edge-agents");
expect(navigationSource).toContain("/superuser/configuration/edgegateway");
expect(configurationNavigationSource).toContain("Edge Gateway");
expect(configurationNavigationSource).toContain("/superuser/configuration/edgegateway");
@@ -410,7 +410,7 @@ describe("superuser system status dashboard", () => {
expect(wrapper.get('[data-testid="gateway-summary-card-online"]').text()).toContain("4");
expect(wrapper.get('[data-testid="gateway-summary-card-degraded"]').text()).toContain("3");
expect(wrapper.get('[data-testid="gateway-summary-card-offline"]').text()).toContain("3");
expect(wrapper.get('a[href="/superuser/configuration/edgegateway"]').text()).toContain("Open fleet");
expect(wrapper.get('a[href="/superuser/selfserve/edge-agents"]').text()).toContain("Open fleet");
const gatewayCards = wrapper.findAll('[data-testid^="gateway-card-"]');
expect(gatewayCards).toHaveLength(8);
@@ -433,7 +433,7 @@ describe("superuser system status dashboard", () => {
"Discovery has not reported in 30 minutes."
);
expect(wrapper.get('[data-testid="gateway-card-207"]').text()).toContain("gw-207.truckwash.test");
expect(wrapper.find('a[href="/superuser/configuration/edgegateway/201/overview"]').exists()).toBe(true);
expect(wrapper.find('a[href="/superuser/selfserve/edge-agents/201/overview"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="gateway-card-209"]').exists()).toBe(false);
wrapper.unmount();