Align edge gateway workspace with route contract

This commit is contained in:
Jeppe Bundgaard
2026-04-23 12:20:33 +02:00
parent 85f24bdb0c
commit 90eb67ba8c
5 changed files with 736 additions and 62 deletions
+662 -29
View File
@@ -6,6 +6,9 @@ import {
createEdgeGatewayOperation,
deleteEdgeGateway,
getEdgeGateway,
getEdgeGatewayInstallTokenStatus,
getEdgeGatewayLogs,
getEdgeGatewayStatistics,
listEdgeGatewayDepartments,
listEdgeGateways,
peekCachedEdgeGateway,
@@ -16,11 +19,15 @@ import {
setDepartmentGatewayCutover,
updateEdgeGateway,
} from "@/services/edgeGateways.js";
import { createGatewayShellClient } from "@/features/edgeGateways/edgeGatewayLiveSessions.js";
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
import EdgeGatewayOverviewPage from "@/features/edgeGateways/EdgeGatewayOverviewPage.vue";
import EdgeGatewayInventoryPage from "@/features/edgeGateways/EdgeGatewayInventoryPage.vue";
import EdgeGatewayOperationsPage from "@/features/edgeGateways/EdgeGatewayOperationsPage.vue";
import EdgeGatewayManagePage from "@/features/edgeGateways/EdgeGatewayManagePage.vue";
import EdgeGatewayTasksPage from "@/features/edgeGateways/EdgeGatewayTasksPage.vue";
import EdgeGatewayLogsPage from "@/features/edgeGateways/EdgeGatewayLogsPage.vue";
import EdgeGatewayStatisticsPage from "@/features/edgeGateways/EdgeGatewayStatisticsPage.vue";
import EdgeGatewayTerminalPage from "@/features/edgeGateways/EdgeGatewayTerminalPage.vue";
import EdgeGatewaySettingsPage from "@/features/edgeGateways/EdgeGatewaySettingsPage.vue";
const props = defineProps({
departmentId: { type: Number, default: null },
@@ -87,44 +94,88 @@ const fleetFilter = ref("ALL");
const fleetUsage = ref(createEmptyFleetUsage());
const rotateBundle = ref(null);
const editableBindings = ref([]);
const metadataForm = ref({ label: "", is_primary: false });
const transportModeDraft = ref("gateway");
const rotateConfirmation = ref("");
const deleteConfirmation = ref("");
const installerStatus = ref(null);
const installerDiagnosticsCopyState = ref("idle");
const selectedOperationId = ref(null);
const updateTargetVersion = ref("");
const logsTimeline = ref([]);
const gatewayShellSessions = ref([]);
const statisticsSnapshot = ref({});
const streamStatus = ref("idle");
const streamError = ref("");
const terminalStatus = ref("idle");
const terminalError = ref("");
const terminalTranscript = ref("");
const terminalCommand = ref("");
const terminalSession = ref(null);
const loading = ref({
init: false,
detail: false,
installer: false,
discovery: false,
bindings: false,
logs: false,
metadata: false,
statistics: false,
cutover: false,
rotate: false,
delete: false,
operation: false,
operationCancel: false,
terminal: false,
});
let autoRefreshHandle = null;
let lastFleetRefreshAt = 0;
let shellClient = null;
let shellGatewayId = null;
const tabs = computed(() =>
props.allowDestructive
? [
{ id: "overview", label: "Overview" },
{ id: "tasks", label: "Tasks" },
{ id: "logs", label: "Logs" },
{ id: "statistics", label: "Statistics" },
{ id: "terminal", label: "Terminal" },
{ id: "inventory", label: "Inventory" },
{ id: "operations", label: "Operations" },
{ id: "manage", label: "Manage" },
{ id: "settings", label: "Settings" },
]
: [
{ id: "overview", label: "Overview" },
{ id: "tasks", label: "Tasks" },
{ id: "logs", label: "Logs" },
{ id: "statistics", label: "Statistics" },
{ id: "inventory", label: "Inventory" },
{ id: "operations", label: "Operations" },
]
);
const currentView = computed(() => (props.routeDriven ? props.activeView : localView.value));
const currentView = computed(() => {
const nextView = props.routeDriven ? props.activeView : localView.value;
if (nextView === "operations") {
return "tasks";
}
if (nextView === "manage") {
return "settings";
}
return nextView;
});
const hasGateway = computed(() => Boolean(selectedGateway.value));
const showFleetLanding = computed(() => !hasGateway.value && !unavailableGatewayId.value);
const installerCopyLabel = computed(() =>
installerCopyState.value === "copied" ? "Copied" : installerCopyState.value === "error" ? "Retry" : "Copy command"
);
const installerDiagnosticsCopyLabel = computed(() =>
installerDiagnosticsCopyState.value === "copied"
? "Copied"
: installerDiagnosticsCopyState.value === "error"
? "Retry"
: "Copy diagnostics"
);
const selectedDepartmentId = computed(() => props.departmentId ?? selectedGatewayView.value?.department_id ?? null);
const errorDetailsText = computed(() => (errorState.value?.details || []).join("\n"));
@@ -136,6 +187,19 @@ const tone = (status) => ({ ONLINE: "success", DEGRADED: "warning", OFFLINE: "da
const statusLabel = (status) => ({ ONLINE: "Online", DEGRADED: "Needs attention", OFFLINE: "Offline" }[status] || "Unknown");
const discoveryLabel = (status) => ({ READY: "Ready", STALE: "Stale", PENDING: "Pending", FAILED: "Failed" }[status] || "Unknown");
const transportLabel = (mode) => ({ gateway: "Gateway", cloud: "Cloud" }[mode] || mode || "Unknown");
const installerStateLabel = (status) =>
({ PENDING: "Pending", RUNNING: "Running", FAILED: "Failed", COMPLETED: "Completed" }[
String(status || "").toUpperCase()
] || "Pending");
const installerStepLabel = (step) =>
({
VERIFY_TOKEN: "Verifying token",
INSTALL_PACKAGES: "Installing packages",
DOWNLOAD_ARTIFACTS: "Downloading artifacts",
WRITE_CONFIG: "Writing config",
START_STACK: "Starting stack",
WAIT_FOR_CLAIM: "Waiting for claim",
}[String(step || "").toUpperCase()] || "Waiting to start");
const deptName = (id) => departments.value.find((item) => Number(item.id) === Number(id))?.name || `Department ${id}`;
const metricText = (value, suffix = "") => (Number.isFinite(Number(value)) ? `${Number(value)}${suffix}` : "No data");
const normalizeFleetUsage = (value) => {
@@ -302,6 +366,30 @@ const view = (gateway) => {
};
const selectedGatewayView = computed(() => view(selectedGateway.value));
const gatewayOperations = computed(() =>
Array.isArray(selectedGatewayView.value?.operations) ? selectedGatewayView.value.operations : []
);
const recentCommands = computed(() =>
Array.isArray(selectedGatewayView.value?.recent_commands) ? selectedGatewayView.value.recent_commands : []
);
const operationSummary = computed(() => selectedGatewayView.value?.recent_operations_summary || {});
const selectedOperation = computed(
() => gatewayOperations.value.find((operation) => String(operation.id) === String(selectedOperationId.value)) || null
);
const selectedOperationEvents = computed(() =>
Array.isArray(selectedOperation.value?.events) ? selectedOperation.value.events : []
);
const installerStatusView = computed(() => {
if (!installerStatus.value) {
return null;
}
return {
...installerStatus.value,
stateLabel: installerStateLabel(installerStatus.value.status),
stepLabel: installerStepLabel(installerStatus.value.step),
};
});
const summaryCards = computed(() => {
const rows = gateways.value.map(view).filter(Boolean);
@@ -385,6 +473,116 @@ const syncDrafts = (gateway) => {
}));
};
const buildLogsTimelineFromGateway = (gateway) => {
const auditLogs = Array.isArray(gateway?.audit_logs) ? gateway.audit_logs : [];
const logEntries = Array.isArray(gateway?.log_entries) ? gateway.log_entries : [];
const operations = Array.isArray(gateway?.operations) ? gateway.operations : [];
const operationEvents = operations.flatMap((operation) =>
(operation?.events || []).map((entry) => ({
type: "operation_event",
level: entry.level || "INFO",
message: entry.message || entry.code || "",
created_at: entry.created_at || null,
entry: {
...entry,
operation_id: operation.id,
operation_type: operation.type,
},
}))
);
return [
...auditLogs.map((entry) => ({
type: "audit",
level: entry.severity || "INFO",
message: entry.action || "AUDIT_EVENT",
created_at: entry.created_at || null,
entry,
})),
...logEntries.map((entry) => ({
type: "log",
level: entry.level || "INFO",
message: entry.message || "",
created_at: entry.created_at || null,
entry,
})),
...operationEvents,
].sort((left, right) => String(right.created_at || "").localeCompare(String(left.created_at || "")));
};
const buildStatisticsSnapshotFromGateway = (gateway) => ({
system_metrics: gateway?.metadata?.system_metrics || gateway?.agent_runtime?.system_metrics || {},
backlog_depth: gateway?.backlog_depth || {},
channel_status: gateway?.channel_status || {},
transport_health: gateway?.transport_health || {},
version_drift:
gateway?.version_drift || {
installed_version: gateway?.installed_version || null,
target_version: gateway?.target_version || null,
is_drifted:
Boolean(gateway?.installed_version) &&
Boolean(gateway?.target_version) &&
String(gateway.installed_version) !== String(gateway.target_version),
},
fleet_usage: buildFleetUsageFromRows(gateway ? [gateway] : []),
});
const resetGatewayViewState = () => {
rotateBundle.value = null;
rotateConfirmation.value = "";
deleteConfirmation.value = "";
selectedOperationId.value = null;
updateTargetVersion.value = "";
logsTimeline.value = [];
gatewayShellSessions.value = [];
statisticsSnapshot.value = {};
streamStatus.value = "idle";
streamError.value = "";
terminalStatus.value = "idle";
terminalError.value = "";
terminalTranscript.value = "";
terminalCommand.value = "";
terminalSession.value = null;
};
const syncGatewayState = (gateway, { resetViewState = false } = {}) => {
if (!gateway) {
return;
}
if (resetViewState) {
resetGatewayViewState();
}
syncDrafts(gateway);
metadataForm.value = {
label: String(gateway.label || ""),
is_primary: Boolean(gateway.is_primary),
};
transportModeDraft.value = gateway.department_transport_mode || "gateway";
if (!updateTargetVersion.value) {
updateTargetVersion.value = String(gateway.target_version || "");
}
if (!logsTimeline.value.length) {
logsTimeline.value = buildLogsTimelineFromGateway(gateway);
}
if (!Object.keys(statisticsSnapshot.value || {}).length) {
statisticsSnapshot.value = buildStatisticsSnapshotFromGateway(gateway);
}
if (
!selectedOperationId.value ||
!gatewayOperations.value.some((operation) => String(operation.id) === String(selectedOperationId.value))
) {
selectedOperationId.value = String(gateway.active_operation?.id || gatewayOperations.value[0]?.id || "");
}
};
const formatInstallerDiagnostics = () =>
(installerStatusView.value?.diagnostics || [])
.map((entry) => `${entry.name || "Diagnostic"}\n${entry.output || "No output"}`)
.join("\n\n");
const clearError = () => {
errorState.value = null;
};
@@ -392,6 +590,7 @@ const clearError = () => {
const scheduleInstallerCopyReset = () => {
window.setTimeout(() => {
installerCopyState.value = "idle";
installerDiagnosticsCopyState.value = "idle";
}, 1800);
};
@@ -504,9 +703,19 @@ const mergeGateway = (gateway) => {
};
const clearSelection = () => {
if (shellClient) {
try {
shellClient.close();
} catch (_error) {
// Best effort.
}
shellClient = null;
shellGatewayId = null;
}
selectedGateway.value = null;
activeGatewayId.value = null;
unavailableGatewayId.value = null;
resetGatewayViewState();
};
const setSelectedGatewaySnapshot = (gateway) => {
@@ -514,11 +723,21 @@ const setSelectedGatewaySnapshot = (gateway) => {
return null;
}
const previousGatewayId = normId(selectedGateway.value?.id);
const mergedGateway = mergeGateway(gateway) || gateway;
if (previousGatewayId && previousGatewayId !== String(mergedGateway.id) && shellClient) {
try {
shellClient.close();
} catch (_error) {
// Best effort.
}
shellClient = null;
shellGatewayId = null;
}
selectedGateway.value = mergedGateway;
activeGatewayId.value = String(mergedGateway.id);
unavailableGatewayId.value = null;
syncDrafts(mergedGateway);
syncGatewayState(mergedGateway, { resetViewState: previousGatewayId !== String(mergedGateway.id) });
return mergedGateway;
};
@@ -776,6 +995,31 @@ const pollForClaimedGateway = async ({ existingIds, baseline, departmentId, labe
}
};
const pollInstallerStatus = async (claimTokenId, pollToken) => {
for (let attempt = 0; attempt < 60; attempt += 1) {
if (installerClaimPollToken.value !== pollToken) {
return;
}
try {
const response = await getEdgeGatewayInstallTokenStatus(claimTokenId);
installerStatus.value = unwrap(response, null);
if (
["FAILED", "COMPLETED"].includes(String(installerStatus.value?.status || "").toUpperCase()) ||
installerStatus.value?.gateway_id
) {
return;
}
} catch (error) {
fail(error);
return;
}
await sleep(1500);
}
};
const generateInstaller = async () => {
const departmentId = Number(installerDepartmentId.value || selectedDepartmentId.value || 0);
if (!departmentId) {
@@ -785,16 +1029,29 @@ const generateInstaller = async () => {
loading.value.installer = true;
installerCopyState.value = "idle";
installerDiagnosticsCopyState.value = "idle";
try {
const label = installerLabel.value.trim();
const pollToken = installerClaimPollToken.value + 1;
const existingIds = new Set(gateways.value.map((item) => Number(item.id)));
const baseline = buildGatewayClaimBaseline(gateways.value);
const response = await createEdgeGatewayInstallToken({ department_id: departmentId, label: label || undefined });
const installToken = unwrap(response, null);
installerClaimPollToken.value = pollToken;
installerCommand.value = unwrap(response, null)?.install_command || "";
installerCommand.value = installToken?.install_command || "";
installerStatus.value = {
claim_token_id: installToken?.claim_token_id ?? null,
status: "PENDING",
step: null,
message: "Waiting for the installer to start.",
diagnostics: [],
last_error: null,
};
flashMessage.value = "Installer token generated.";
clearError();
if (installToken?.claim_token_id) {
pollInstallerStatus(installToken.claim_token_id, pollToken).catch(() => {});
}
pollForClaimedGateway({ existingIds, baseline, departmentId, label, pollToken }).catch(() => {});
} catch (error) {
fail(error);
@@ -824,11 +1081,38 @@ const copyInstallerCommand = async () => {
scheduleInstallerCopyReset();
};
const saveMetadata = async (payload) => {
const copyInstallerDiagnostics = async () => {
if (!formatInstallerDiagnostics()) {
return;
}
try {
const copied = await copyPlainText(formatInstallerDiagnostics());
installerDiagnosticsCopyState.value = copied ? "copied" : "error";
flashMessage.value = copied ? "Installer diagnostics copied." : "Could not copy installer diagnostics.";
} catch (_error) {
installerDiagnosticsCopyState.value = "error";
flashMessage.value = "Could not copy installer diagnostics.";
}
scheduleInstallerCopyReset();
};
const updateMetadataField = (field, value) => {
metadataForm.value = {
...metadataForm.value,
[field]: field === "is_primary" ? Boolean(value) : value,
};
};
const saveMetadata = async () => {
if (!selectedGateway.value?.id) return;
loading.value.metadata = true;
try {
const response = await updateEdgeGateway(selectedGateway.value.id, payload);
const response = await updateEdgeGateway(selectedGateway.value.id, {
label: String(metadataForm.value.label || "").trim(),
is_primary: Boolean(metadataForm.value.is_primary),
});
const gateway = unwrap(response, null);
if (gateway) {
setSelectedGatewaySnapshot(gateway);
@@ -842,12 +1126,16 @@ const saveMetadata = async (payload) => {
}
};
const applyCutover = async (transportMode) => {
const updateTransportMode = (value) => {
transportModeDraft.value = value || "gateway";
};
const saveTransportMode = async () => {
if (!selectedDepartmentId.value) return;
loading.value.cutover = true;
try {
await setDepartmentGatewayCutover(selectedDepartmentId.value, transportMode);
applyDepartmentTransportModeLocally(selectedDepartmentId.value, transportMode);
await setDepartmentGatewayCutover(selectedDepartmentId.value, transportModeDraft.value);
applyDepartmentTransportModeLocally(selectedDepartmentId.value, transportModeDraft.value);
flashMessage.value = "Department cutover updated.";
clearError();
} catch (error) {
@@ -857,6 +1145,14 @@ const applyCutover = async (transportMode) => {
}
};
const updateRotateConfirmation = (value) => {
rotateConfirmation.value = String(value || "");
};
const updateDeleteConfirmation = (value) => {
deleteConfirmation.value = String(value || "");
};
const queueOperation = async ({ type, request }) => {
if (!selectedGateway.value?.id) return;
loading.value.operation = true;
@@ -865,6 +1161,7 @@ const queueOperation = async ({ type, request }) => {
const gateway = unwrap(response, null)?.gateway || response?.data?.data?.gateway || null;
if (gateway) {
setSelectedGatewaySnapshot(gateway);
selectedOperationId.value = String(gateway.active_operation?.id || gateway.operations?.[0]?.id || "");
}
flashMessage.value = `Gateway operation ${type} queued.`;
clearError();
@@ -875,6 +1172,23 @@ const queueOperation = async ({ type, request }) => {
}
};
const queueUpdate = async () => {
await queueOperation({
type: "UPDATE",
request: {
target_version: String(updateTargetVersion.value || selectedGateway.value?.target_version || "").trim(),
},
});
};
const updateTargetVersionDraft = (value) => {
updateTargetVersion.value = String(value || "");
};
const queueUninstall = async () => {
await queueOperation({ type: "UNINSTALL", request: {} });
};
const cancelOperation = async (operation) => {
if (!selectedGateway.value?.id || !operation?.id) return;
loading.value.operationCancel = true;
@@ -896,6 +1210,25 @@ const cancelOperation = async (operation) => {
}
};
const selectOperation = (operation) => {
if (!operation?.id) {
return;
}
selectedOperationId.value = String(operation.id);
};
const retryOperation = async (operation) => {
if (!operation?.type) {
return;
}
await queueOperation({
type: operation.type,
request: operation.request || operation.result || {},
});
};
const queueDiscovery = async () => {
const previousInventoryCount = selectedGateway.value?.inventory?.length || 0;
await queueOperation({ type: "DISCOVERY", request: {} });
@@ -944,6 +1277,163 @@ const saveBindingsAction = async () => {
}
};
const loadLogs = async (gatewayId = selectedGateway.value?.id) => {
if (!gatewayId) {
return;
}
loading.value.logs = true;
try {
const response = await getEdgeGatewayLogs(gatewayId);
const payload = unwrap(response, {});
if (payload?.gateway) {
setSelectedGatewaySnapshot(payload.gateway);
}
logsTimeline.value = Array.isArray(payload?.timeline) ? payload.timeline : buildLogsTimelineFromGateway(payload?.gateway);
gatewayShellSessions.value = Array.isArray(payload?.shell_sessions) ? payload.shell_sessions : [];
clearError();
} catch (error) {
fail(error);
} finally {
loading.value.logs = false;
}
};
const loadStatistics = async (gatewayId = selectedGateway.value?.id) => {
if (!gatewayId) {
return;
}
loading.value.statistics = true;
try {
const response = await getEdgeGatewayStatistics(gatewayId);
const payload = unwrap(response, {});
if (payload?.gateway) {
setSelectedGatewaySnapshot(payload.gateway);
}
statisticsSnapshot.value = payload || {};
clearError();
} catch (error) {
fail(error);
} finally {
loading.value.statistics = false;
}
};
const connectTerminal = async () => {
const gatewayId = normId(selectedGateway.value?.id);
if (!gatewayId) {
return;
}
if (shellClient && shellGatewayId === gatewayId && ["connecting", "open"].includes(terminalStatus.value)) {
return;
}
if (shellClient) {
try {
shellClient.close();
} catch (_error) {
// Best effort.
}
shellClient = null;
shellGatewayId = null;
}
loading.value.terminal = true;
terminalStatus.value = "connecting";
terminalError.value = "";
terminalTranscript.value = "";
terminalCommand.value = "";
terminalSession.value = null;
try {
const client = await createGatewayShellClient(
gatewayId,
{
reason: "Interactive diagnostic terminal",
cols: 120,
rows: 28,
cwd: "/opt/truckwash-edge-agent",
},
{
onOpen() {
terminalStatus.value = "open";
},
onMessage(message) {
if (message?.type === "output") {
terminalTranscript.value = `${terminalTranscript.value}${String(message.data || "")}`;
return;
}
if (message?.type === "closed") {
terminalStatus.value = "closed";
return;
}
},
onError(error) {
terminalStatus.value = "error";
terminalError.value = normalizeEdgeGatewayError(error).message || "Terminal session failed.";
},
onClose() {
if (shellClient === client) {
shellClient = null;
shellGatewayId = null;
if (terminalStatus.value !== "error") {
terminalStatus.value = "closed";
}
}
},
}
);
shellClient = client;
shellGatewayId = gatewayId;
terminalSession.value = client.session;
} catch (error) {
terminalStatus.value = "error";
terminalError.value = normalizeEdgeGatewayError(error).message || "Terminal session failed.";
} finally {
loading.value.terminal = false;
}
};
const disconnectTerminal = () => {
if (shellClient) {
try {
shellClient.close();
} catch (_error) {
// Best effort.
}
shellClient = null;
shellGatewayId = null;
}
terminalStatus.value = "closed";
};
const updateTerminalInput = (value) => {
terminalCommand.value = value;
};
const sendTerminalCommand = () => {
if (!shellClient || terminalStatus.value !== "open" || !String(terminalCommand.value || "").trim()) {
return;
}
shellClient.sendInput(terminalCommand.value);
terminalCommand.value = "";
};
const runTerminalShortcut = (command) => {
if (!shellClient || terminalStatus.value !== "open") {
terminalCommand.value = String(command || "");
return;
}
shellClient.sendInput(command);
terminalCommand.value = "";
};
const rotateCredentials = async () => {
if (!selectedGateway.value?.id) return;
loading.value.rotate = true;
@@ -1030,7 +1520,7 @@ const autoRefreshTick = async () => {
if (selectedGateway.value?.id) {
const shouldRefreshDetail =
currentView.value === "overview" ||
currentView.value === "operations" ||
currentView.value === "tasks" ||
Boolean(selectedGateway.value.active_operation);
const refreshes = [];
if (shouldRefreshDetail) {
@@ -1087,6 +1577,39 @@ watch(installerCommand, () => {
installerCopyState.value = "idle";
});
watch(
[() => currentView.value, () => selectedGateway.value?.id],
([viewId, gatewayId], previous = []) => {
const [previousViewId, previousGatewayId] = previous;
const normalizedGatewayId = normId(gatewayId);
if (
previousViewId === "terminal" &&
(viewId !== "terminal" || String(previousGatewayId || "") !== String(normalizedGatewayId || ""))
) {
disconnectTerminal();
}
if (!normalizedGatewayId) {
return;
}
if (viewId === "logs") {
loadLogs(normalizedGatewayId).catch(() => {});
return;
}
if (viewId === "statistics") {
loadStatistics(normalizedGatewayId).catch(() => {});
return;
}
if (viewId === "terminal") {
connectTerminal().catch(() => {});
}
},
{ immediate: true }
);
onMounted(() => {
if (props.departmentId) {
installerDepartmentId.value = String(props.departmentId);
@@ -1096,6 +1619,13 @@ onMounted(() => {
onUnmounted(() => {
stopAutoRefresh();
if (shellClient) {
try {
shellClient.close();
} catch (_error) {
// Best effort.
}
}
});
</script>
@@ -1243,6 +1773,47 @@ onUnmounted(() => {
rows="4"
data-testid="gateway-install-command"
></textarea>
<article v-if="installerStatusView" class="edge-installer-status" data-testid="gateway-installer-status">
<div class="edge-panel-head">
<div>
<p class="edge-card-label">Installer session</p>
<strong data-testid="gateway-installer-status-state">{{ installerStatusView.stateLabel }}</strong>
</div>
<small>{{ installerStatusView.updated_at || installerStatusView.started_at || "Awaiting update" }}</small>
</div>
<p data-testid="gateway-installer-status-step">{{ installerStatusView.stepLabel }}</p>
<p>{{ installerStatusView.message || "Waiting for installer progress." }}</p>
<p v-if="installerStatusView.last_error" data-testid="gateway-installer-status-error">
{{ installerStatusView.last_error }}
</p>
<details
v-if="installerStatusView.diagnostics?.length"
class="edge-installer-status__diagnostics"
data-testid="gateway-installer-status-diagnostics"
>
<summary>Diagnostics</summary>
<div class="edge-diagnostics">
<article
v-for="entry in installerStatusView.diagnostics"
:key="`${entry.name}-${entry.output}`"
class="edge-diagnostic-item"
>
<strong>{{ entry.name || "Diagnostic" }}</strong>
<pre class="edge-json-block">{{ entry.output || "No output" }}</pre>
</article>
</div>
</details>
<button
v-if="installerStatusView.diagnostics?.length"
type="button"
class="button is-light"
data-testid="gateway-installer-copy-diagnostics"
@click="copyInstallerDiagnostics"
>
{{ installerDiagnosticsCopyLabel }}
</button>
</article>
</div>
</section>
@@ -1284,38 +1855,86 @@ onUnmounted(() => {
<EdgeGatewayInventoryPage
v-else-if="currentView === 'inventory'"
:gateway="selectedGatewayView"
:editable-bindings="editableBindings"
:loading-discovery="loading.discovery || loading.operation"
:loading-bindings="loading.bindings"
:can-edit="true"
@queue-discovery="queueDiscovery"
:bindings="editableBindings"
:busy="loading.discovery || loading.bindings || loading.operation"
@trigger-discovery="queueDiscovery"
@add-binding="addBindingRow"
@remove-binding="removeBindingRow"
@update-binding="updateBinding"
@save-bindings="saveBindingsAction"
/>
<EdgeGatewayOperationsPage
v-else-if="currentView === 'operations'"
:gateway="selectedGatewayView"
<EdgeGatewayTasksPage
v-else-if="currentView === 'tasks'"
:operations="gatewayOperations"
:recent-commands="recentCommands"
:operation-summary="operationSummary"
:selected-operation-id="selectedOperationId"
:operation-events="selectedOperationEvents"
:allow-destructive="allowDestructive"
:loading-create="loading.operation"
:loading-cancel="loading.operationCancel"
@run-operation="queueOperation"
:busy="loading.operation || loading.operationCancel"
:update-target-version="updateTargetVersion"
:stream-status="streamStatus"
:stream-error="streamError"
@queue-discovery="queueDiscovery"
@queue-update="queueUpdate"
@queue-uninstall="queueUninstall"
@cancel-operation="cancelOperation"
@retry-operation="retryOperation"
@select-operation="selectOperation"
@update-target-version="updateTargetVersionDraft"
/>
<EdgeGatewayManagePage
v-else-if="currentView === 'manage' && allowDestructive"
<EdgeGatewayLogsPage
v-else-if="currentView === 'logs'"
:timeline="logsTimeline"
:shell-sessions="gatewayShellSessions"
:stream-status="streamStatus"
:stream-error="streamError"
/>
<EdgeGatewayStatisticsPage
v-else-if="currentView === 'statistics'"
:statistics="statisticsSnapshot"
:stream-status="streamStatus"
:stream-error="streamError"
/>
<EdgeGatewayTerminalPage
v-else-if="currentView === 'terminal' && allowDestructive"
:status="terminalStatus"
:error="terminalError"
:transcript="terminalTranscript"
:command="terminalCommand"
:session="terminalSession"
:busy="loading.terminal"
@connect="connectTerminal"
@disconnect="disconnectTerminal"
@send-command="sendTerminalCommand"
@update-command="updateTerminalInput"
@shortcut="runTerminalShortcut"
/>
<EdgeGatewaySettingsPage
v-else-if="currentView === 'settings' && allowDestructive"
:gateway="selectedGatewayView"
:metadata-form="metadataForm"
:transport-mode="transportModeDraft"
:rotate-confirmation="rotateConfirmation"
:delete-confirmation="deleteConfirmation"
:credential-bundle="rotateBundle"
:busy="loading.metadata || loading.cutover || loading.rotate || loading.delete"
:loading-metadata="loading.metadata"
:loading-cutover="loading.cutover"
:loading-rotate="loading.rotate"
:loading-delete="loading.delete"
:rotate-bundle="rotateBundle"
@update-metadata="updateMetadataField"
@save-metadata="saveMetadata"
@apply-cutover="applyCutover"
@update-transport-mode="updateTransportMode"
@save-transport-mode="saveTransportMode"
@update-rotate-confirmation="updateRotateConfirmation"
@rotate-credentials="rotateCredentials"
@update-delete-confirmation="updateDeleteConfirmation"
@delete-gateway="removeGateway"
/>
</section>
@@ -1498,6 +2117,20 @@ onUnmounted(() => {
word-break: break-word;
}
.edge-installer-status {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid rgba(32, 41, 58, 0.08);
border-radius: 14px;
background: rgba(248, 250, 252, 0.9);
}
.edge-installer-status__diagnostics {
display: grid;
gap: 0.75rem;
}
@media (max-width: 960px) {
.edge-v2-layout {
grid-template-columns: 1fr;
@@ -6,7 +6,12 @@ const props = defineProps({
type: Object,
required: true,
},
showOpenFullPage: {
type: Boolean,
default: false,
},
});
defineEmits(["open-full-page"]);
const formatValue = (value, suffix = "") => (Number.isFinite(Number(value)) ? `${Number(value)}${suffix}` : "No data");
const formatDate = (value) => (value ? String(value) : "No data");
@@ -17,6 +22,17 @@ const metrics = computed(() => props.gateway?.metadata?.system_metrics || props.
<template>
<section class="edge-gateway-overview" data-testid="gateway-overview-page">
<div v-if="showOpenFullPage" class="edge-gateway-overview__actions">
<button
class="button is-light"
type="button"
data-testid="gateway-open-full-page"
@click="$emit('open-full-page')"
>
Open full page
</button>
</div>
<div class="edge-gateway-overview__grid">
<article class="edge-gateway-overview__card">
<p class="edge-gateway-overview__eyebrow">Gateway status</p>
@@ -105,6 +121,11 @@ const metrics = computed(() => props.gateway?.metadata?.system_metrics || props.
gap: 1rem;
}
.edge-gateway-overview__actions {
display: flex;
justify-content: flex-end;
}
.edge-gateway-overview__grid {
display: grid;
gap: 1rem;
+49 -31
View File
@@ -2,43 +2,35 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
const overviewSource = readFileSync(
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"),
"utf8"
);
const inventorySource = readFileSync(
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"),
"utf8"
);
const operationsSource = readFileSync(
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"),
"utf8"
);
const manageSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManagePage.vue"), "utf8");
const routerSource = readFileSync(join(process.cwd(), "src/router.js"), "utf8");
const root = process.cwd();
const managerSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
const overviewSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"), "utf8");
const inventorySource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"), "utf8");
const tasksSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayTasksPage.vue"), "utf8");
const logsSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayLogsPage.vue"), "utf8");
const statisticsSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayStatisticsPage.vue"), "utf8");
const terminalSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayTerminalPage.vue"), "utf8");
const settingsSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewaySettingsPage.vue"), "utf8");
const routerSource = readFileSync(join(root, "src/router.js"), "utf8");
const edgeGatewaysPageSource = readFileSync(
join(process.cwd(), "src/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue"),
join(root, "src/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue"),
"utf8"
);
const departmentGatewaysPageSource = readFileSync(
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
join(root, "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
"utf8"
);
const navigationSource = readFileSync(
join(process.cwd(), "src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue"),
join(root, "src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue"),
"utf8"
);
const configurationNavigationSource = readFileSync(
join(
process.cwd(),
"src/views/dashboards/superUserDashboard/configuration/SuperUserDashboardConfigurationNavigation.vue"
),
join(root, "src/views/dashboards/superUserDashboard/configuration/SuperUserDashboardConfigurationNavigation.vue"),
"utf8"
);
describe("edge gateway workspace contract", () => {
it("splits the module UI into overview, inventory, operations, and manage modules", () => {
it("splits the module UI into route-aligned overview, tasks, logs, statistics, terminal, inventory, and settings pages", () => {
expect(managerSource).toContain('data-testid="edge-gateway-workspace"');
expect(managerSource).toContain('data-testid="gateway-summary-strip"');
expect(managerSource).toContain('data-testid="gateway-fleet-usage"');
@@ -47,23 +39,37 @@ describe("edge gateway workspace contract", () => {
expect(managerSource).toContain('data-testid="gateway-installer-status-step"');
expect(managerSource).toContain('data-testid="gateway-installer-status-state"');
expect(managerSource).toContain('data-testid="gateway-installer-copy-diagnostics"');
expect(managerSource).toContain('id: "tasks"');
expect(managerSource).toContain('id: "logs"');
expect(managerSource).toContain('id: "statistics"');
expect(managerSource).toContain('id: "terminal"');
expect(managerSource).toContain('id: "settings"');
expect(managerSource).toContain("import EdgeGatewayOverviewPage");
expect(managerSource).toContain("import EdgeGatewayInventoryPage");
expect(managerSource).toContain("import EdgeGatewayOperationsPage");
expect(managerSource).toContain("import EdgeGatewayManagePage");
expect(managerSource).toContain("import EdgeGatewayTasksPage");
expect(managerSource).toContain("import EdgeGatewayLogsPage");
expect(managerSource).toContain("import EdgeGatewayStatisticsPage");
expect(managerSource).toContain("import EdgeGatewayTerminalPage");
expect(managerSource).toContain("import EdgeGatewaySettingsPage");
expect(overviewSource).toContain('data-testid="gateway-overview-page"');
expect(overviewSource).toContain('data-testid="gateway-open-full-page"');
expect(overviewSource).toContain('data-testid="gateway-overview-container-health"');
expect(overviewSource).toContain('data-testid="gateway-overview-container-services"');
expect(overviewSource).toContain('data-testid="gateway-overview-outbox"');
expect(overviewSource).toContain('data-testid="gateway-overview-update-window"');
expect(overviewSource).toContain('data-testid="gateway-overview-rollout"');
expect(inventorySource).toContain('data-testid="gateway-inventory-page"');
expect(operationsSource).toContain('data-testid="gateway-operations-page"');
expect(operationsSource).toContain('data-testid="gateway-operation-cancel"');
expect(operationsSource).toContain('data-testid="gateway-operation-retry"');
expect(manageSource).toContain('data-testid="gateway-manage-page"');
expect(manageSource).toContain('data-testid="gateway-rotate-confirmation"');
expect(manageSource).toContain('data-testid="gateway-delete-confirmation"');
expect(tasksSource).toContain('data-testid="gateway-tasks-page"');
expect(tasksSource).toContain('data-testid="gateway-operation-cancel"');
expect(tasksSource).toContain('data-testid="gateway-operation-retry"');
expect(logsSource).toContain('data-testid="gateway-logs-page"');
expect(statisticsSource).toContain('data-testid="gateway-statistics-page"');
expect(terminalSource).toContain('data-testid="gateway-terminal-page"');
expect(terminalSource).toContain('data-testid="gateway-terminal-status"');
expect(settingsSource).toContain('data-testid="gateway-settings-page"');
expect(settingsSource).toContain('data-testid="gateway-rotate-confirmation"');
expect(settingsSource).toContain('data-testid="gateway-delete-confirmation"');
});
it("mounts a module workspace and a safe-subset department workspace", () => {
@@ -80,12 +86,24 @@ describe("edge gateway workspace contract", () => {
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('/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('/settings`');
expect(routerSource).toContain("path: '/superuser/gateways'");
expect(routerSource).toContain("configure: 'inventory'");
expect(routerSource).toContain("assess: 'overview'");
+3 -1
View File
@@ -39,6 +39,8 @@ describe("weatherapi superuser wiring", () => {
expect(leftMenuSource).toContain("SessionUser.superUser.modules.weatherapi.meta.title");
expect(leftMenuSource).toContain("SessionUser.superUser.modules.weatherapi.meta.config_endpoint");
expect(configTabsSource).toContain("{ name: 'WeatherAPI', path: '/superuser/configuration/weatherapi' }");
expect(configTabsSource).toMatch(
/name:\s*["']WeatherAPI["']\s*,\s*path:\s*["']\/superuser\/configuration\/weatherapi["']/
);
});
});
+1 -1
View File
@@ -39,6 +39,6 @@ describe("workfeed superuser wiring", () => {
expect(leftMenuSource).toContain("SessionUser.superUser.modules.workfeed.meta.title");
expect(leftMenuSource).toContain("SessionUser.superUser.modules.workfeed.meta.config_endpoint");
expect(configTabsSource).toContain("{ name: 'Workfeed', path: '/superuser/configuration/workfeed' }");
expect(configTabsSource).toMatch(/name:\s*["']Workfeed["']\s*,\s*path:\s*["']\/superuser\/configuration\/workfeed["']/);
});
});