Files
pleno-vue/src/features/edgeGateways/EdgeGatewayManager.vue
T

2806 lines
88 KiB
Vue

<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import {
cancelEdgeGatewayOperation,
clearEdgeGatewayWorkspaceCache,
createEdgeGatewayInstallToken,
createEdgeGatewayOperation,
deleteEdgeGateway,
getEdgeGateway,
getEdgeGatewayInstallTokenStatus,
getEdgeGatewayLogs,
getEdgeGatewayOperationEvents,
getEdgeGatewayStatistics,
getEdgeGatewayTasks,
listEdgeGatewayDepartments,
isEdgeGatewayAuthorizationError,
listEdgeGateways,
peekCachedEdgeGateway,
peekCachedEdgeGatewayDepartments,
peekCachedEdgeGatewayList,
removeEdgeGatewayCache,
rotateEdgeGatewayCredentials,
saveEdgeGatewayBindings,
setDepartmentGatewayCutover,
updateEdgeGateway,
} from "@/services/edgeGateways.js";
import { normalizeEdgeGatewayError, normalizeGatewayWebSocketClose, redactEdgeGatewayDiagnostic } from "@/features/edgeGateways/edgeGatewayErrors.js";
import EdgeGatewayOverviewPage from "@/features/edgeGateways/EdgeGatewayOverviewPage.vue";
import EdgeGatewayInventoryPage from "@/features/edgeGateways/EdgeGatewayInventoryPage.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 EdgeGatewayManagePage from "@/features/edgeGateways/EdgeGatewayManagePage.vue";
import { createGatewayShellClient, createGatewayStreamClient } from "@/features/edgeGateways/edgeGatewayLiveSessions.js";
const props = defineProps({
departmentId: { type: Number, default: null },
selectedGatewayId: { type: [Number, String], default: null },
activeView: { type: String, default: "overview" },
routeDriven: { type: Boolean, default: false },
allowDestructive: { type: Boolean, default: true },
});
const emit = defineEmits(["navigate", "open-gateway-page"]);
const gateways = ref([]);
const departments = ref([]);
const selectedGateway = ref(null);
const activeGatewayId = ref(null);
const localView = ref("overview");
const createEmptyFleetUsage = () => ({
gateways: {
total: 0,
departments: 0,
online: 0,
offline: 0,
degraded: 0,
drifted: 0,
broker_connected: 0,
},
inventory: {
total: 0,
online: 0,
offline: 0,
},
bindings: {
total: 0,
fallback_overrides: 0,
cloud_only: 0,
local_only: 0,
},
operations: {
active: 0,
pending: 0,
in_progress: 0,
backlog: 0,
},
commands: {
backlog: 0,
},
system: {
latency_ms_avg: null,
cpu_usage_pct_avg: null,
memory_usage_pct_avg: null,
disk_usage_pct_avg: null,
},
});
const installerDepartmentId = ref("");
const installerLabel = ref("");
const installerCommand = ref("");
const installerCopyState = ref("idle");
const installerClaimPollToken = ref(0);
const installerSession = ref(null);
const flashMessage = ref("");
const errorState = ref(null);
const unavailableGatewayId = ref(null);
const fleetQuery = ref("");
const fleetFilter = ref("ALL");
const fleetUsage = ref(createEmptyFleetUsage());
const tasksSnapshot = ref(null);
const logsSnapshot = ref(null);
const statisticsSnapshot = ref(null);
const selectedOperationId = ref(null);
const selectedOperationEvents = ref([]);
const updateTargetVersion = ref("");
const terminalStatus = ref("idle");
const terminalError = ref("");
const terminalErrorDetails = ref([]);
const terminalTranscript = ref("");
const terminalCommand = ref("");
const terminalSession = ref(null);
const terminalBusy = ref(false);
const gatewayStreamStatus = ref("idle");
const gatewayStreamError = ref("");
const rotateBundle = ref(null);
const editableBindings = ref([]);
const editableBindingsGatewayId = ref(null);
const bindingsDirty = ref(false);
const loading = ref({
init: false,
detail: false,
installer: false,
discovery: false,
bindings: false,
metadata: false,
cutover: false,
rotate: false,
delete: false,
operation: false,
operationCancel: false,
});
let autoRefreshHandle = null;
let gatewayStreamClient = null;
let gatewayStreamToken = 0;
let terminalClient = null;
let terminalSessionToken = 0;
let liveGatewaySnapshot = null;
let liveGatewaySnapshotAt = 0;
let lastFleetRefreshAt = 0;
const supportedGatewayViews = Object.freeze(["overview", "inventory", "tasks", "logs", "statistics", "terminal", "settings"]);
const installerTerminalStates = new Set(["CLAIMED", "FAILED", "EXPIRED", "CANCELLED"]);
const installerDeadlineFallbackMs = 15 * 60 * 1000;
const parseTimeValue = (value) => {
const parsed = Date.parse(String(value || ""));
return Number.isFinite(parsed) ? parsed : null;
};
const normalizeViewId = (value) => {
const normalized = String(value || "overview")
.trim()
.toLowerCase();
if (normalized === "operations") {
return "tasks";
}
if (normalized === "manage") {
return "settings";
}
return supportedGatewayViews.includes(normalized) ? normalized : "overview";
};
const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
const appendTerminalOutput = (data) => {
terminalTranscript.value = `${terminalTranscript.value || ""}${String(data || "")}`;
};
const normalizeTerminalClosePayload = (message = {}) => {
if (typeof message === "string") {
return { reason: message };
}
return isRecord(message) ? message : {};
};
const terminalGatewayBrokerDiagnostics = (gateway = selectedGatewayView.value) => {
const broker = isRecord(gateway?.channel_status?.broker) ? gateway.channel_status.broker : {};
const presence = isRecord(gateway?.metadata?.broker_presence) ? gateway.metadata.broker_presence : {};
const lines = [];
const push = (label, value) => {
if (value === null || value === undefined || value === "") {
return;
}
lines.push(`${label}: ${redactEdgeGatewayDiagnostic(value)}`);
};
push("Gateway ID", gateway?.id);
push("Broker connected", Boolean(broker.connected ?? presence.connected) ? "yes" : "no");
push("Broker state", broker.state);
push("Broker connection", presence.connection_id);
push("Broker last seen", broker.last_seen_at || presence.last_seen_at);
push("Broker age", Number.isFinite(Number(broker.age_seconds ?? presence.age_seconds))
? `${Number(broker.age_seconds ?? presence.age_seconds)}s`
: null);
push("Broker last error", broker.last_error || presence.last_error || gateway?.metadata?.broker_last_error);
push("Broker disconnect reason", broker.disconnect_reason || presence.disconnect_reason);
return lines;
};
const terminalBrokerReadinessBlock = (gateway = selectedGatewayView.value) => {
const broker = isRecord(gateway?.channel_status?.broker) ? gateway.channel_status.broker : {};
const presence = isRecord(gateway?.metadata?.broker_presence) ? gateway.metadata.broker_presence : {};
const hasBrokerSignal =
Object.prototype.hasOwnProperty.call(broker, "connected") ||
Object.prototype.hasOwnProperty.call(presence, "connected") ||
Object.prototype.hasOwnProperty.call(gateway?.metadata || {}, "broker_connected");
const connected = Boolean(broker.connected ?? presence.connected ?? gateway?.metadata?.broker_connected);
const stale =
/stale/i.test(String(broker.state || presence.state || "")) ||
(Number.isFinite(Number(broker.age_seconds ?? presence.age_seconds)) &&
Number(broker.age_seconds ?? presence.age_seconds) > 90);
if (!hasBrokerSignal || (connected && !stale)) {
return null;
}
return {
message: stale
? "Gateway broker presence is stale. Wait for the agent to reconnect before opening a shell."
: "Gateway agent is not connected to the broker. Restart the edge agent or check the broker URL.",
details: terminalGatewayBrokerDiagnostics(gateway),
};
};
const terminalCloseDiagnostics = (message = {}) => {
const normalized = normalizeGatewayWebSocketClose(normalizeTerminalClosePayload(message), {
diagnostics: terminalSession.value?.diagnostics || terminalSession.value?.session?.metadata?.shell_diagnostics || null,
});
const lines = [...(normalized.details || [])];
const sessionId = terminalSession.value?.session?.id || terminalSession.value?.id || null;
if (sessionId) {
lines.push(`Session: #${sessionId}`);
}
lines.push(...terminalGatewayBrokerDiagnostics());
return {
...normalized,
details: Array.from(new Set(lines.filter(Boolean))),
};
};
const terminalCloseMessage = (message = {}, status = terminalStatus.value) => {
const closePayload = terminalCloseDiagnostics(message);
const reason = String(closePayload.reason || "").trim();
const brokerMessage = String(closePayload.message || "").trim();
const code = Number(closePayload.code);
if (brokerMessage) {
return brokerMessage;
}
if (reason === "agent_offline") {
return "Gateway agent is not connected to the broker. Restart the edge agent or check the broker URL.";
}
if (reason === "agent_disconnected") {
return "Gateway agent disconnected from the broker.";
}
if (reason === "agent_exit") {
return "Gateway shell exited.";
}
if (reason === "shell_open_timeout") {
return "Gateway agent did not confirm that the shell opened before the broker timeout.";
}
if (reason === "shell_spawn_failed") {
return "Gateway failed to start the shell process.";
}
if (reason === "shell_session_expired") {
return "The terminal session expired before the shell opened.";
}
if (reason && reason !== "socket_closed") {
return `Terminal connection closed: ${reason}.`;
}
if (status === "connecting") {
if (Number.isFinite(code) && code !== 1000) {
return `Terminal connection closed before the gateway shell opened (WebSocket ${code}).`;
}
return "Terminal connection closed before the gateway shell opened.";
}
return "";
};
const applyTerminalClosed = (message = {}) => {
const closeDiagnostics = terminalCloseDiagnostics(message);
const closeMessage = terminalCloseMessage(message);
terminalStatus.value = closeMessage ? "error" : "closed";
terminalError.value = closeMessage;
terminalErrorDetails.value = closeMessage ? closeDiagnostics.details : [];
terminalBusy.value = false;
terminalClient = null;
};
const closeTerminalClient = () => {
terminalSessionToken += 1;
const client = terminalClient;
terminalClient = null;
if (!client) {
return;
}
try {
client.close?.();
} catch (_error) {
// The socket may still be connecting; local state is already detached.
}
};
const resolveInstallerDeadlineAt = (expiresAt, currentDeadlineAt = null) => {
const parsedExpiresAt = parseTimeValue(expiresAt);
if (parsedExpiresAt && parsedExpiresAt > Date.now()) {
return parsedExpiresAt;
}
if (Number(currentDeadlineAt || 0) > Date.now()) {
return Number(currentDeadlineAt);
}
return Date.now() + installerDeadlineFallbackMs;
};
const mergeInstallerSession = (nextSession, currentSession = null) => {
if (!nextSession && !currentSession) {
return null;
}
const merged = {
...(currentSession || {}),
...(nextSession || {}),
};
merged.deadlineAt = resolveInstallerDeadlineAt(merged.expires_at, currentSession?.deadlineAt);
merged.diagnostics = Array.isArray(merged.diagnostics) ? merged.diagnostics : [];
merged.events = Array.isArray(merged.events) ? merged.events : [];
merged.terminal = Boolean(merged.terminal) || installerTerminalStates.has(String(merged.status || "").toUpperCase());
return merged;
};
const installerStateLabel = (status) =>
(
{
PENDING: "Pending",
RUNNING: "Running",
CLAIMED: "Connected",
FAILED: "Failed",
EXPIRED: "Expired",
CANCELLED: "Cancelled",
}[String(status || "").toUpperCase()] || "Unknown"
);
const installerStateTone = (status) =>
(
{
PENDING: "info",
RUNNING: "warning",
CLAIMED: "success",
FAILED: "danger",
EXPIRED: "danger",
CANCELLED: "warning",
}[String(status || "").toUpperCase()] || "light"
);
const installerStepLabel = (step) =>
(
{
PENDING: "Waiting to start",
VERIFY_TOKEN: "Verifying claim token",
INSTALL_PACKAGES: "Installing packages",
DOWNLOAD_ARTIFACTS: "Downloading artifacts",
WRITE_CONFIG: "Writing configuration",
START_STACK: "Starting stack",
CLAIMED: "Gateway connected",
FAILED: "Failed",
EXPIRED: "Expired",
}[String(step || "").toUpperCase()] || String(step || "Unknown")
);
const isInstallerTerminal = (session) =>
Boolean(session?.terminal) || installerTerminalStates.has(String(session?.status || "").toUpperCase());
const fullGatewayTabs = Object.freeze([
{ id: "overview", label: "Overview" },
{ id: "inventory", label: "Inventory" },
{ id: "tasks", label: "Tasks" },
{ id: "logs", label: "Logs" },
{ id: "statistics", label: "Statistics" },
{ id: "terminal", label: "Terminal" },
{ id: "settings", label: "Settings" },
]);
const readOnlyGatewayTabs = Object.freeze([
{ id: "overview", label: "Overview" },
{ id: "inventory", label: "Inventory" },
{ id: "tasks", label: "Tasks" },
{ id: "logs", label: "Logs" },
{ id: "statistics", label: "Statistics" },
]);
const tabs = computed(() =>
props.allowDestructive ? fullGatewayTabs : readOnlyGatewayTabs
);
const normalizeAvailableViewId = (viewId) => {
const normalizedViewId = normalizeViewId(viewId);
return tabs.value.some((tab) => tab.id === normalizedViewId) ? normalizedViewId : "overview";
};
const currentView = computed(() => normalizeAvailableViewId(props.routeDriven ? props.activeView : localView.value));
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 selectedDepartmentId = computed(() => props.departmentId ?? selectedGatewayView.value?.department_id ?? null);
const errorDetailsText = computed(() => (errorState.value?.details || []).join("\n"));
const installerSessionView = computed(() => {
if (!installerSession.value) {
return null;
}
return {
...installerSession.value,
stateLabel: installerStateLabel(installerSession.value.status),
stateTone: installerStateTone(installerSession.value.status),
stepLabel: installerStepLabel(installerSession.value.step),
lastError: installerSession.value.last_error || "",
};
});
const unwrap = (response, fallback = null) => response?.data?.data ?? fallback;
const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
const normId = (value) => (value === null || value === undefined || value === "" ? null : String(value));
const normLabel = (value) => String(value || "").trim();
const tone = (status) => ({ ONLINE: "success", DEGRADED: "warning", OFFLINE: "danger" }[status] || "neutral");
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 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) => {
const incoming = value && typeof value === "object" ? value : {};
return {
gateways: {
...createEmptyFleetUsage().gateways,
...(incoming.gateways || {}),
},
inventory: {
...createEmptyFleetUsage().inventory,
...(incoming.inventory || {}),
},
bindings: {
...createEmptyFleetUsage().bindings,
...(incoming.bindings || {}),
},
operations: {
...createEmptyFleetUsage().operations,
...(incoming.operations || {}),
},
commands: {
...createEmptyFleetUsage().commands,
...(incoming.commands || {}),
},
system: {
...createEmptyFleetUsage().system,
...(incoming.system || {}),
},
};
};
const sumGatewayValue = (rows = [], selector) => rows.reduce((sum, gateway) => sum + Number(selector(gateway) || 0), 0);
const averageGatewayMetric = (rows = [], metricKey) => {
const values = rows
.map((gateway) => gateway?.agent_runtime?.system_metrics?.[metricKey] ?? gateway?.metadata?.system_metrics?.[metricKey])
.filter((value) => Number.isFinite(Number(value)))
.map((value) => Number(value));
if (!values.length) {
return null;
}
return Math.round(values.reduce((sum, value) => sum + value, 0) / values.length);
};
const buildFleetUsageFromRows = (rows = []) => {
const departmentsCovered = new Set(
rows
.map((gateway) => Number(gateway?.department_id || 0))
.filter((departmentId) => Number.isFinite(departmentId) && departmentId > 0)
);
return normalizeFleetUsage({
gateways: {
total: rows.length,
departments: departmentsCovered.size,
online: rows.filter((gateway) => gateway?.status === "ONLINE").length,
offline: rows.filter((gateway) => gateway?.status === "OFFLINE").length,
degraded: rows.filter((gateway) => gateway?.status === "DEGRADED").length,
drifted: rows.filter((gateway) => Boolean(gateway?.version_drift?.is_drifted)).length,
broker_connected: rows.filter(
(gateway) => Boolean(gateway?.channel_status?.broker?.connected ?? gateway?.metadata?.broker_connected)
).length,
},
inventory: {
total: sumGatewayValue(rows, (gateway) => gateway?.inventory_summary?.total ?? gateway?.inventory?.length),
online: sumGatewayValue(
rows,
(gateway) =>
gateway?.inventory_summary?.online ??
(Array.isArray(gateway?.inventory) ? gateway.inventory.filter((device) => device?.online !== false).length : 0)
),
offline: sumGatewayValue(
rows,
(gateway) =>
gateway?.inventory_summary?.offline ??
(Array.isArray(gateway?.inventory) ? gateway.inventory.filter((device) => device?.online === false).length : 0)
),
},
bindings: {
total: sumGatewayValue(rows, (gateway) => gateway?.binding_summary?.total ?? gateway?.bindings?.length),
fallback_overrides: sumGatewayValue(
rows,
(gateway) =>
gateway?.binding_summary?.fallback_overrides ??
(Array.isArray(gateway?.bindings)
? gateway.bindings.filter((binding) => String(binding?.fallback_mode || "PREFER_LOCAL") !== "PREFER_LOCAL").length
: 0)
),
cloud_only: sumGatewayValue(rows, (gateway) => gateway?.fallback_summary?.cloud_only_relays),
local_only: sumGatewayValue(rows, (gateway) => gateway?.fallback_summary?.local_only_relays),
},
operations: {
active: rows.filter((gateway) => Boolean(gateway?.active_operation)).length,
pending: sumGatewayValue(rows, (gateway) => gateway?.recent_operations_summary?.pending),
in_progress: sumGatewayValue(rows, (gateway) => gateway?.recent_operations_summary?.in_progress),
backlog: sumGatewayValue(rows, (gateway) => gateway?.backlog_depth?.operations),
},
commands: {
backlog: sumGatewayValue(rows, (gateway) => gateway?.backlog_depth?.commands),
},
system: {
latency_ms_avg: averageGatewayMetric(rows, "latency_ms"),
cpu_usage_pct_avg: averageGatewayMetric(rows, "cpu_usage_pct"),
memory_usage_pct_avg: averageGatewayMetric(rows, "memory_usage_pct"),
disk_usage_pct_avg: averageGatewayMetric(rows, "disk_usage_pct"),
},
});
};
const buildOperationSummary = (operations = []) =>
(Array.isArray(operations) ? operations : []).reduce(
(summary, operation) => {
const status = String(operation?.status || "PENDING").toUpperCase();
if (status === "COMPLETED") summary.completed += 1;
else if (status === "FAILED") summary.failed += 1;
else if (status === "IN_PROGRESS") summary.in_progress += 1;
else summary.pending += 1;
return summary;
},
{ completed: 0, failed: 0, pending: 0, in_progress: 0 }
);
const buildLocalGatewayTimeline = (gateway) => {
if (!gateway) {
return [];
}
const auditEntries = (Array.isArray(gateway.audit_logs) ? gateway.audit_logs : []).map((entry) => ({
type: "audit",
level: entry?.severity || "INFO",
message: entry?.action || "AUDIT_EVENT",
created_at: entry?.created_at || null,
entry,
}));
const logEntries = (Array.isArray(gateway.log_entries) ? gateway.log_entries : []).map((entry) => ({
type: String(entry?.stream || "").toLowerCase() === "relay" ? "relay" : "log",
level: entry?.level || "INFO",
message: entry?.message || "",
created_at: entry?.created_at || null,
entry,
}));
const operationEntries = (Array.isArray(gateway.operations) ? gateway.operations : []).flatMap((operation) =>
(Array.isArray(operation?.events) ? 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 [...auditEntries, ...logEntries, ...operationEntries].sort(
(left, right) => new Date(String(right?.created_at || 0)).getTime() - new Date(String(left?.created_at || 0)).getTime()
);
};
const snapshotGatewayClaimState = (gateway) => ({
lastHeartbeatAt: gateway?.last_heartbeat_at || null,
status: gateway?.status || null,
lastSeenIp: gateway?.last_seen_ip || null,
});
const buildGatewayClaimBaseline = (rows = []) =>
new Map(rows.map((gateway) => [String(gateway.id), snapshotGatewayClaimState(gateway)]));
const gatewayClaimStateChanged = (gateway, baseline) => {
if (!baseline) {
return false;
}
return (
String(gateway?.last_heartbeat_at || "") !== String(baseline.lastHeartbeatAt || "") ||
String(gateway?.status || "") !== String(baseline.status || "") ||
String(gateway?.last_seen_ip || "") !== String(baseline.lastSeenIp || "")
);
};
const sortByMostRecentHeartbeat = (rows = []) =>
[...rows].sort(
(left, right) => new Date(String(right?.last_heartbeat_at || 0)).getTime() - new Date(String(left?.last_heartbeat_at || 0)).getTime()
);
const view = (gateway) => {
if (!gateway) {
return null;
}
const inventory = Array.isArray(gateway.inventory) ? gateway.inventory : [];
const bindings = Array.isArray(gateway.bindings) ? gateway.bindings : [];
const activeOperation = gateway.active_operation || null;
return {
...gateway,
inventory,
bindings,
active_operation: activeOperation,
displayLabel: gateway.label || gateway.hostname || `Gateway #${gateway.id}`,
departmentName: deptName(gateway.department_id),
statusLabel: statusLabel(gateway.status),
statusTone: tone(gateway.status),
discoveryStatusLabel: discoveryLabel(gateway.discovery_status),
departmentTransportModeLabel: transportLabel(gateway.department_transport_mode),
primaryAction:
gateway.error_state?.recommended_action ||
gateway.transport_health?.recommended_action ||
activeOperation?.summary?.label ||
"review",
};
};
const selectedGatewayView = computed(() => view(selectedGateway.value));
const taskOperations = computed(() => {
if (Array.isArray(tasksSnapshot.value?.operations)) {
return tasksSnapshot.value.operations;
}
return Array.isArray(selectedGatewayView.value?.operations) ? selectedGatewayView.value.operations : [];
});
const taskRecentCommands = computed(() => {
if (Array.isArray(tasksSnapshot.value?.recent_commands)) {
return tasksSnapshot.value.recent_commands;
}
return Array.isArray(selectedGatewayView.value?.recent_commands) ? selectedGatewayView.value.recent_commands : [];
});
const taskOperationSummary = computed(() => {
if (tasksSnapshot.value?.recent_operations_summary && typeof tasksSnapshot.value.recent_operations_summary === "object") {
return tasksSnapshot.value.recent_operations_summary;
}
if (selectedGatewayView.value?.recent_operations_summary && typeof selectedGatewayView.value.recent_operations_summary === "object") {
return selectedGatewayView.value.recent_operations_summary;
}
return buildOperationSummary(taskOperations.value);
});
const taskUpdateTargetVersion = computed(
() =>
String(
updateTargetVersion.value ||
selectedGatewayView.value?.staged_version?.target_version ||
selectedGatewayView.value?.target_version ||
""
)
);
const logTimeline = computed(() => {
if (Array.isArray(logsSnapshot.value?.timeline)) {
return logsSnapshot.value.timeline;
}
return buildLocalGatewayTimeline(selectedGatewayView.value);
});
const logShellSessions = computed(() => (Array.isArray(logsSnapshot.value?.shell_sessions) ? logsSnapshot.value.shell_sessions : []));
const logRelayLogs = computed(() => {
if (Array.isArray(logsSnapshot.value?.relay_logs)) {
return logsSnapshot.value.relay_logs;
}
const fallbackEntries = Array.isArray(logsSnapshot.value?.log_entries)
? logsSnapshot.value.log_entries
: selectedGatewayView.value?.log_entries;
return (Array.isArray(fallbackEntries) ? fallbackEntries : []).filter(
(entry) => String(entry?.stream || "").toLowerCase() === "relay"
);
});
const statisticsPageState = computed(() => {
if (statisticsSnapshot.value && typeof statisticsSnapshot.value === "object" && Object.keys(statisticsSnapshot.value).length) {
return statisticsSnapshot.value;
}
return selectedGatewayView.value
? {
gateway: selectedGatewayView.value,
fleet_usage: fleetUsage.value,
channel_status: selectedGatewayView.value.channel_status || {},
transport_health: selectedGatewayView.value.transport_health || {},
backlog_depth: selectedGatewayView.value.backlog_depth || {},
container_health: selectedGatewayView.value.container_health || {},
system_metrics:
selectedGatewayView.value?.metadata?.system_metrics || selectedGatewayView.value?.agent_runtime?.system_metrics || {},
version_drift: selectedGatewayView.value.version_drift || {},
}
: {};
});
const summaryCards = computed(() => {
const rows = gateways.value.map(view).filter(Boolean);
return [
{ key: "ALL", label: "All", count: rows.length },
{ key: "ONLINE", label: "Online", count: rows.filter((item) => item.status === "ONLINE").length },
{ key: "OFFLINE", label: "Offline", count: rows.filter((item) => item.status === "OFFLINE").length },
{
key: "DRIFT",
label: "Version drift",
count: rows.filter((item) => Boolean(item.version_drift?.is_drifted)).length,
},
];
});
const fleetUsageCards = computed(() => [
{
key: "coverage",
label: "Fleet coverage",
value: metricText(fleetUsage.value.gateways.departments),
detail: `${fleetUsage.value.gateways.total} gateways · ${fleetUsage.value.gateways.online} online`,
},
{
key: "devices",
label: "Discovered devices",
value: metricText(fleetUsage.value.inventory.total),
detail: `${fleetUsage.value.inventory.online} online · ${fleetUsage.value.inventory.offline} offline`,
},
{
key: "bindings",
label: "Relay bindings",
value: metricText(fleetUsage.value.bindings.total),
detail: `${fleetUsage.value.bindings.fallback_overrides} overrides · ${fleetUsage.value.bindings.cloud_only} cloud only`,
},
{
key: "operations",
label: "Active operations",
value: metricText(fleetUsage.value.operations.active),
detail: `${fleetUsage.value.operations.backlog} queued · ${fleetUsage.value.operations.pending} pending`,
},
{
key: "commands",
label: "Command backlog",
value: metricText(fleetUsage.value.commands.backlog),
detail: `${fleetUsage.value.gateways.broker_connected}/${fleetUsage.value.gateways.total} broker links active`,
},
{
key: "runtime",
label: "Runtime averages",
value: metricText(fleetUsage.value.system.latency_ms_avg, " ms"),
detail: `CPU ${metricText(fleetUsage.value.system.cpu_usage_pct_avg, "%")} · Memory ${metricText(
fleetUsage.value.system.memory_usage_pct_avg,
"%"
)}`,
},
]);
const filteredGatewayViews = computed(() => {
const query = fleetQuery.value.trim().toLowerCase();
return gateways.value
.map(view)
.filter(Boolean)
.filter((gateway) => {
if (fleetFilter.value === "ONLINE" && gateway.status !== "ONLINE") return false;
if (fleetFilter.value === "OFFLINE" && gateway.status !== "OFFLINE") return false;
if (fleetFilter.value === "DRIFT" && !gateway.version_drift?.is_drifted) return false;
if (!query) return true;
return [gateway.displayLabel, gateway.departmentName, gateway.hostname, gateway.primaryAction].some((value) =>
String(value || "").toLowerCase().includes(query)
);
});
});
const markBindingsDirty = () => {
editableBindingsGatewayId.value = normId(selectedGateway.value?.id);
bindingsDirty.value = true;
};
const syncDrafts = (gateway, { force = false } = {}) => {
const gatewayId = normId(gateway?.id);
if (!gatewayId) {
editableBindingsGatewayId.value = null;
bindingsDirty.value = false;
editableBindings.value = [];
return;
}
if (!force && bindingsDirty.value && editableBindingsGatewayId.value === gatewayId) {
return;
}
editableBindingsGatewayId.value = gatewayId;
bindingsDirty.value = false;
editableBindings.value = (gateway?.bindings || []).map((binding) => ({
id: binding.id,
relay_id: binding.relay_id || "",
device_id: binding.device_id || "",
local_ip: binding.local_ip || "",
channel: Number(binding.channel ?? 0),
fallback_mode: binding.fallback_mode || "PREFER_LOCAL",
binding_source: binding.binding_source || "MANUAL",
consumer_contexts: Array.isArray(binding.consumer_contexts) ? binding.consumer_contexts : [],
consumers: Array.isArray(binding.consumers) ? binding.consumers : [],
metadata: binding.metadata && typeof binding.metadata === "object" ? { ...binding.metadata } : undefined,
}));
};
const clearError = () => {
errorState.value = null;
};
const scheduleInstallerCopyReset = () => {
window.setTimeout(() => {
installerCopyState.value = "idle";
}, 1800);
};
const copyPlainText = async (value) => {
const text = String(value || "");
if (!text.trim()) {
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 copyErrorDetails = async () => {
if (!errorDetailsText.value) {
return;
}
const copied = await copyPlainText(errorDetailsText.value);
flashMessage.value = copied ? "Copied error details." : "Could not copy error details.";
};
const fail = (error) => {
flashMessage.value = "";
errorState.value = normalizeEdgeGatewayError(error);
};
const mergeGatewaySnapshots = (currentGateway, nextGateway) => {
if (!currentGateway) {
return { ...(nextGateway || {}) };
}
return {
...currentGateway,
...(nextGateway || {}),
metadata: {
...(currentGateway?.metadata || {}),
...(nextGateway?.metadata || {}),
},
inventory: Array.isArray(nextGateway?.inventory)
? [...nextGateway.inventory]
: Array.isArray(currentGateway?.inventory)
? [...currentGateway.inventory]
: undefined,
bindings: Array.isArray(nextGateway?.bindings)
? [...nextGateway.bindings]
: Array.isArray(currentGateway?.bindings)
? [...currentGateway.bindings]
: undefined,
operations: Array.isArray(nextGateway?.operations)
? [...nextGateway.operations]
: Array.isArray(currentGateway?.operations)
? [...currentGateway.operations]
: undefined,
audit_logs: Array.isArray(nextGateway?.audit_logs)
? [...nextGateway.audit_logs]
: Array.isArray(currentGateway?.audit_logs)
? [...currentGateway.audit_logs]
: undefined,
};
};
const liveSnapshotOmittedKeys = [
"inventory",
"bindings",
"operations",
"audit_logs",
"recent_commands",
"log_entries",
"active_operation",
"recent_operations_summary",
"inventory_summary",
"binding_summary",
"fallback_summary",
"relay_health",
"transport_health",
"version_drift",
"staged_version",
"target_version",
"release_channel",
"label",
"is_primary",
"transport_mode",
"department_transport_mode",
"credential_freshness",
"diagnostics",
"error_state",
"readiness",
"last_successful_discovery_at",
];
const withoutLiveSnapshotStaleFields = (gateway) => {
if (!isRecord(gateway)) {
return gateway;
}
const snapshot = { ...gateway };
liveSnapshotOmittedKeys.forEach((key) => {
delete snapshot[key];
});
return snapshot;
};
const rememberLiveGatewaySnapshot = (gateway) => {
if (!gateway?.id) {
return null;
}
const runtimeGateway = withoutLiveSnapshotStaleFields(gateway);
liveGatewaySnapshot =
liveGatewaySnapshot && Number(liveGatewaySnapshot.id) === Number(gateway.id)
? withoutLiveSnapshotStaleFields(mergeGatewaySnapshots(liveGatewaySnapshot, runtimeGateway))
: { ...runtimeGateway };
liveGatewaySnapshotAt = Date.now();
return liveGatewaySnapshot;
};
const hasCurrentLiveGatewaySnapshot = (gatewayId) =>
Boolean(
liveGatewaySnapshot?.id &&
gatewayId &&
Number(liveGatewaySnapshot.id) === Number(gatewayId) &&
["open", "connected"].includes(String(gatewayStreamStatus.value || "")) &&
Date.now() - Number(liveGatewaySnapshotAt || 0) < 120000
);
const gatewayWithLiveSnapshot = (gateway) => {
if (!gateway?.id || !hasCurrentLiveGatewaySnapshot(gateway.id)) {
return gateway;
}
return mergeGatewaySnapshots(gateway, liveGatewaySnapshot);
};
const statisticsWithLiveSnapshot = (statistics = {}, { live = false } = {}) => {
if (!isRecord(statistics)) {
return {};
}
const sourceGateway = statistics.gateway
? live
? mergeGatewaySnapshots(selectedGateway.value, withoutLiveSnapshotStaleFields(statistics.gateway))
: statistics.gateway
: selectedGateway.value;
const gateway = gatewayWithLiveSnapshot(sourceGateway);
if (!gateway?.id) {
return { ...statistics };
}
const metrics =
gateway.metadata?.system_metrics || gateway.agent_runtime?.system_metrics || statistics.system_metrics;
return {
...statistics,
gateway,
container_health: gateway.container_health || statistics.container_health,
system_metrics: isRecord(metrics) ? metrics : statistics.system_metrics,
};
};
const applyFleetRows = (rows = [], meta = null) => {
gateways.value = Array.isArray(rows) ? [...rows] : [];
fleetUsage.value = normalizeFleetUsage(meta || buildFleetUsageFromRows(gateways.value));
lastFleetRefreshAt = Date.now();
};
const mergeGateway = (gateway) => {
if (!gateway?.id) {
return null;
}
const index = gateways.value.findIndex((item) => Number(item.id) === Number(gateway.id));
if (index === -1) {
gateways.value = [gateway, ...gateways.value];
} else {
gateways.value = gateways.value.map((item, itemIndex) =>
itemIndex === index ? mergeGatewaySnapshots(item, gateway) : item
);
}
fleetUsage.value = buildFleetUsageFromRows(gateways.value);
return gateways.value.find((item) => Number(item?.id || 0) === Number(gateway.id)) || gateway;
};
const resetGatewayViewSnapshots = () => {
closeTerminalClient();
tasksSnapshot.value = null;
logsSnapshot.value = null;
statisticsSnapshot.value = null;
selectedOperationId.value = null;
selectedOperationEvents.value = [];
updateTargetVersion.value = "";
terminalStatus.value = "idle";
terminalError.value = "";
terminalTranscript.value = "";
terminalCommand.value = "";
terminalSession.value = null;
terminalBusy.value = false;
};
const clearSelection = () => {
selectedGateway.value = null;
activeGatewayId.value = null;
unavailableGatewayId.value = null;
resetGatewayViewSnapshots();
};
const setSelectedGatewaySnapshot = (gateway) => {
if (!gateway?.id) {
return null;
}
const previousGatewayId = String(activeGatewayId.value || "");
const preferredGateway = gatewayWithLiveSnapshot(gateway);
const mergedGateway = mergeGateway(preferredGateway) || preferredGateway;
if (previousGatewayId && previousGatewayId !== String(mergedGateway.id)) {
resetGatewayViewSnapshots();
}
selectedGateway.value = mergedGateway;
activeGatewayId.value = String(mergedGateway.id);
unavailableGatewayId.value = null;
syncDrafts(mergedGateway);
if (!String(updateTargetVersion.value || "").trim()) {
updateTargetVersion.value = String(mergedGateway?.staged_version?.target_version || mergedGateway?.target_version || "");
}
return mergedGateway;
};
const findLocalGatewaySnapshot = (gatewayId) => {
const normalizedId = normId(gatewayId);
if (!normalizedId) {
return null;
}
if (String(selectedGateway.value?.id || "") === normalizedId) {
return selectedGateway.value;
}
return (
peekCachedEdgeGateway(normalizedId) ||
gateways.value.find((gateway) => String(gateway?.id || "") === normalizedId) ||
null
);
};
const hydrateDepartmentsFromCache = () => {
const cachedDepartments = peekCachedEdgeGatewayDepartments();
if (!Array.isArray(cachedDepartments)) {
return false;
}
departments.value = cachedDepartments;
return true;
};
const hydrateFleetFromCache = () => {
const cachedFleet = peekCachedEdgeGatewayList({ departmentId: props.departmentId ?? null, view: "summary" });
if (!cachedFleet) {
return false;
}
gateways.value = Array.isArray(cachedFleet.rows) ? [...cachedFleet.rows] : [];
fleetUsage.value = normalizeFleetUsage(cachedFleet.meta?.fleet_usage || buildFleetUsageFromRows(gateways.value));
lastFleetRefreshAt = Number(cachedFleet.cachedAt || 0);
return true;
};
const hydrateSelectionFromLocalState = () => {
const preferredId = normId(props.selectedGatewayId);
if (preferredId) {
const cachedGateway = findLocalGatewaySnapshot(preferredId);
if (!cachedGateway) {
return false;
}
setSelectedGatewaySnapshot(cachedGateway);
return true;
}
if (props.routeDriven) {
clearSelection();
return false;
}
if (!gateways.value.length) {
clearSelection();
return false;
}
const firstGateway = findLocalGatewaySnapshot(gateways.value[0]?.id) || gateways.value[0];
if (!firstGateway) {
clearSelection();
return false;
}
setSelectedGatewaySnapshot(firstGateway);
return true;
};
const fleetRefreshDue = (intervalMs = 30000) => Date.now() - Number(lastFleetRefreshAt || 0) >= intervalMs;
const applyDepartmentTransportModeLocally = (departmentId, transportMode) => {
const normalizedDepartmentId = Number(departmentId || 0);
if (!normalizedDepartmentId) {
return;
}
gateways.value = gateways.value.map((gateway) =>
Number(gateway?.department_id || 0) === normalizedDepartmentId
? { ...gateway, department_transport_mode: transportMode }
: gateway
);
if (Number(selectedGateway.value?.department_id || 0) === normalizedDepartmentId) {
selectedGateway.value = {
...selectedGateway.value,
department_transport_mode: transportMode,
};
}
};
const refreshFleet = async ({ forceRefresh = false } = {}) => {
const response = await listEdgeGateways({
departmentId: props.departmentId ?? null,
view: "summary",
forceRefresh,
});
applyFleetRows(unwrap(response, []), response?.data?.meta?.fleet_usage);
return response;
};
const refreshSelected = async (gatewayId = activeGatewayId.value, { forceRefresh = false } = {}) => {
if (!gatewayId) {
clearSelection();
return null;
}
loading.value.detail = true;
try {
const response = await getEdgeGateway(gatewayId, { forceRefresh });
const gateway = unwrap(response, null);
if (gateway) {
setSelectedGatewaySnapshot(gateway);
}
return gateway;
} catch (error) {
if (isEdgeGatewayAuthorizationError(error)) {
removeEdgeGatewayCache(gatewayId);
unavailableGatewayId.value = String(gatewayId);
selectedGateway.value = null;
resetGatewayViewSnapshots();
fail(error);
return null;
}
const fallbackGateway = findLocalGatewaySnapshot(gatewayId);
if (fallbackGateway) {
setSelectedGatewaySnapshot(fallbackGateway);
fail(error);
return fallbackGateway;
}
unavailableGatewayId.value = String(gatewayId);
selectedGateway.value = null;
fail(error);
return null;
} finally {
loading.value.detail = false;
}
};
const syncSelectedOperation = (operations = []) => {
const rows = Array.isArray(operations) ? operations : [];
const currentSelection = rows.find((operation) => String(operation?.id || "") === String(selectedOperationId.value || ""));
const nextSelection = currentSelection || rows[0] || null;
selectedOperationId.value = nextSelection ? String(nextSelection.id) : null;
selectedOperationEvents.value = Array.isArray(nextSelection?.events) ? [...nextSelection.events] : [];
};
const refreshTasksSnapshot = async (gatewayId = activeGatewayId.value) => {
if (!gatewayId) {
tasksSnapshot.value = null;
return null;
}
const response = await getEdgeGatewayTasks(gatewayId);
const payload = unwrap(response, {});
tasksSnapshot.value = payload && typeof payload === "object" ? payload : {};
if (tasksSnapshot.value?.gateway) {
tasksSnapshot.value.gateway = gatewayWithLiveSnapshot(tasksSnapshot.value.gateway);
setSelectedGatewaySnapshot(tasksSnapshot.value.gateway);
}
syncSelectedOperation(tasksSnapshot.value?.operations);
updateTargetVersion.value = String(
tasksSnapshot.value?.gateway?.staged_version?.target_version ||
tasksSnapshot.value?.gateway?.target_version ||
updateTargetVersion.value ||
""
);
return tasksSnapshot.value;
};
const refreshLogsSnapshot = async (gatewayId = activeGatewayId.value) => {
if (!gatewayId) {
logsSnapshot.value = null;
return null;
}
const response = await getEdgeGatewayLogs(gatewayId);
const payload = unwrap(response, {});
logsSnapshot.value = payload && typeof payload === "object" ? payload : {};
if (logsSnapshot.value?.gateway) {
logsSnapshot.value.gateway = gatewayWithLiveSnapshot(logsSnapshot.value.gateway);
setSelectedGatewaySnapshot(logsSnapshot.value.gateway);
}
return logsSnapshot.value;
};
const refreshStatisticsSnapshot = async (gatewayId = activeGatewayId.value) => {
if (!gatewayId) {
statisticsSnapshot.value = null;
return null;
}
const response = await getEdgeGatewayStatistics(gatewayId);
const payload = unwrap(response, {});
statisticsSnapshot.value = statisticsWithLiveSnapshot(payload && typeof payload === "object" ? payload : {});
if (statisticsSnapshot.value?.gateway) {
setSelectedGatewaySnapshot(statisticsSnapshot.value.gateway);
}
return statisticsSnapshot.value;
};
const refreshCurrentViewData = async (gatewayId = activeGatewayId.value) => {
if (!gatewayId) {
return null;
}
if (currentView.value === "tasks") {
return refreshTasksSnapshot(gatewayId);
}
if (currentView.value === "logs") {
return refreshLogsSnapshot(gatewayId);
}
if (currentView.value === "statistics") {
return refreshStatisticsSnapshot(gatewayId);
}
return null;
};
const streamMessageGatewayId = (message = {}) =>
normId(
message.gatewayId ||
message.gateway_id ||
message.gateway?.id ||
message.statistics?.gateway?.id ||
message.telemetry?.gateway_id ||
null
);
const isActiveGatewayStreamMessage = (message = {}) => {
const messageGatewayId = streamMessageGatewayId(message);
return !messageGatewayId || messageGatewayId === normId(activeGatewayId.value);
};
const mergeStatisticsSnapshot = (statistics = {}) => {
if (!isRecord(statistics)) {
return;
}
if (statistics.gateway) {
rememberLiveGatewaySnapshot(statistics.gateway);
}
statisticsSnapshot.value = {
...(isRecord(statisticsSnapshot.value) ? statisticsSnapshot.value : {}),
...statisticsWithLiveSnapshot(statistics, { live: true }),
};
if (statisticsSnapshot.value?.gateway) {
setSelectedGatewaySnapshot(statisticsSnapshot.value.gateway);
}
};
const telemetryGatewayPatch = (message = {}) => {
const eventGateway = isRecord(message.gateway) ? message.gateway : null;
const telemetry = isRecord(message.telemetry) ? message.telemetry : {};
const metadata = isRecord(telemetry.metadata) ? telemetry.metadata : {};
const metrics = isRecord(metadata.system_metrics) ? metadata.system_metrics : null;
const gatewayId = eventGateway?.id || streamMessageGatewayId(message) || activeGatewayId.value;
if (!gatewayId) {
return eventGateway;
}
const patch = {
...(eventGateway || {}),
id: gatewayId,
metadata: {
...(eventGateway?.metadata || {}),
...metadata,
},
};
if (isRecord(eventGateway?.agent_runtime)) patch.agent_runtime = { ...eventGateway.agent_runtime };
if (telemetry.status) patch.status = String(telemetry.status);
if (telemetry.hostname) patch.hostname = String(telemetry.hostname);
if (telemetry.installed_version) patch.installed_version = String(telemetry.installed_version);
if (telemetry.target_version) patch.target_version = String(telemetry.target_version);
if (telemetry.last_heartbeat_at) patch.last_heartbeat_at = String(telemetry.last_heartbeat_at);
if (isRecord(metadata.container_health)) patch.container_health = metadata.container_health;
if (isRecord(metadata.outbox_status)) patch.outbox_status = metadata.outbox_status;
if (metadata.last_sync_at) patch.last_sync_at = String(metadata.last_sync_at);
if (metadata.update_window) patch.update_window = metadata.update_window;
if (isRecord(metadata.staged_version)) patch.staged_version = metadata.staged_version;
if (isRecord(metadata.rollback_status)) patch.rollback_status = metadata.rollback_status;
if (metrics) patch.agent_runtime = { ...(patch.agent_runtime || {}), system_metrics: metrics };
return withoutLiveSnapshotStaleFields(patch);
};
const applyGatewayTelemetry = (message = {}) => {
const patch = telemetryGatewayPatch(message);
if (!patch?.id) {
return;
}
rememberLiveGatewaySnapshot(patch);
const gateway = setSelectedGatewaySnapshot(patch);
if (!gateway) {
return;
}
if (currentView.value !== "statistics" && !isRecord(statisticsSnapshot.value)) {
return;
}
const metrics = patch.metadata?.system_metrics || patch.agent_runtime?.system_metrics || {};
statisticsSnapshot.value = {
...(isRecord(statisticsSnapshot.value) ? statisticsSnapshot.value : {}),
gateway,
fleet_usage: fleetUsage.value,
channel_status: gateway.channel_status || statisticsSnapshot.value?.channel_status || {},
transport_health: gateway.transport_health || statisticsSnapshot.value?.transport_health || {},
backlog_depth: gateway.backlog_depth || statisticsSnapshot.value?.backlog_depth || {},
container_health: gateway.container_health || statisticsSnapshot.value?.container_health || {},
system_metrics: metrics,
version_drift: gateway.version_drift || statisticsSnapshot.value?.version_drift || {},
};
};
const applyGatewayStreamMessage = (message = {}) => {
if (!isRecord(message) || !isActiveGatewayStreamMessage(message)) {
return;
}
if (message.type === "gateway.telemetry") {
applyGatewayTelemetry(message);
return;
}
if (message.type === "stats.updated") {
mergeStatisticsSnapshot(message.statistics);
return;
}
if (message.type === "presence.changed") {
gatewayStreamStatus.value = String(message.status || gatewayStreamStatus.value || "open");
}
};
const closeGatewayStream = () => {
gatewayStreamToken += 1;
if (gatewayStreamClient) {
gatewayStreamClient.close?.("gateway_changed");
gatewayStreamClient = null;
}
gatewayStreamStatus.value = "idle";
gatewayStreamError.value = "";
};
const startGatewayStream = async (gatewayId = activeGatewayId.value) => {
const normalizedGatewayId = normId(gatewayId);
closeGatewayStream();
if (!normalizedGatewayId) {
return;
}
const token = gatewayStreamToken;
gatewayStreamStatus.value = "connecting";
gatewayStreamError.value = "";
try {
const client = await createGatewayStreamClient(normalizedGatewayId, ["overview", "tasks", "logs", "statistics"], {
onOpen: () => {
if (token !== gatewayStreamToken) return;
gatewayStreamStatus.value = "open";
gatewayStreamError.value = "";
},
onMessage: (message) => {
if (token !== gatewayStreamToken) return;
applyGatewayStreamMessage(message);
},
onError: (error) => {
if (token !== gatewayStreamToken) return;
gatewayStreamStatus.value = "error";
gatewayStreamError.value = normalizeEdgeGatewayError(error)?.message || "Gateway stream error.";
},
onClose: () => {
if (token !== gatewayStreamToken) return;
gatewayStreamStatus.value = "closed";
},
});
if (token !== gatewayStreamToken) {
client.close?.("gateway_changed");
return;
}
gatewayStreamClient = client;
} catch (error) {
if (token !== gatewayStreamToken) return;
gatewayStreamStatus.value = "error";
gatewayStreamError.value = normalizeEdgeGatewayError(error)?.message || "Could not open gateway stream.";
}
};
const navigateGateway = (gatewayId, nextView = "overview") => {
if (!gatewayId) {
return;
}
if (props.routeDriven) {
emit("navigate", { gatewayId: String(gatewayId), view: normalizeViewId(nextView) });
} else {
localView.value = normalizeViewId(nextView);
}
};
const selectGateway = async (gatewayId, nextView = currentView.value || "overview") => {
const normalizedId = normId(gatewayId);
if (!normalizedId) {
return;
}
activeGatewayId.value = normalizedId;
clearError();
const cachedGateway = findLocalGatewaySnapshot(normalizedId);
if (cachedGateway) {
setSelectedGatewaySnapshot(cachedGateway);
navigateGateway(normalizedId, nextView || "overview");
refreshSelected(normalizedId).catch(() => {});
return;
}
await refreshSelected(normalizedId);
navigateGateway(normalizedId, nextView || "overview");
};
const syncSelection = async () => {
const preferredId = normId(props.selectedGatewayId);
if (preferredId) {
const cachedGateway = findLocalGatewaySnapshot(preferredId);
if (cachedGateway) {
setSelectedGatewaySnapshot(cachedGateway);
}
await refreshSelected(preferredId);
return;
}
if (props.routeDriven) {
clearSelection();
return;
}
if (gateways.value.length) {
const firstGateway = findLocalGatewaySnapshot(gateways.value[0].id) || gateways.value[0];
if (firstGateway) {
setSelectedGatewaySnapshot(firstGateway);
await refreshSelected(firstGateway.id);
return;
}
} else {
clearSelection();
}
};
const load = async () => {
const hydratedDepartments = hydrateDepartmentsFromCache();
const hydratedFleet = hydrateFleetFromCache();
const hydratedSelection = hydrateSelectionFromLocalState();
loading.value.init = !(hydratedDepartments || hydratedFleet || hydratedSelection);
try {
const [departmentsResponse] = await Promise.all([listEdgeGatewayDepartments(), refreshFleet()]);
departments.value = unwrap(departmentsResponse, []);
await syncSelection();
} catch (error) {
if (isEdgeGatewayAuthorizationError(error)) {
clearEdgeGatewayWorkspaceCache();
gateways.value = [];
fleetUsage.value = buildFleetUsageFromRows([]);
if (activeGatewayId.value) {
unavailableGatewayId.value = String(activeGatewayId.value);
}
selectedGateway.value = null;
resetGatewayViewSnapshots();
}
fail(error);
} finally {
loading.value.init = false;
}
};
const findClaimedGateway = ({ existingIds, baseline, departmentId, label }) => {
const normalizedDepartmentId = Number(departmentId || 0);
const normalizedLabel = normLabel(label);
const matchingGateways = gateways.value.filter((item) => {
if (Number(item.department_id) !== normalizedDepartmentId) return false;
if (normalizedLabel && normLabel(item.label) !== normalizedLabel) return false;
return true;
});
return (
matchingGateways.find((item) => !existingIds.has(Number(item.id))) ||
(() => {
const reusedGateways = sortByMostRecentHeartbeat(
matchingGateways.filter((item) => gatewayClaimStateChanged(item, baseline.get(String(item.id))))
);
if (reusedGateways.length === 1) {
return reusedGateways[0];
}
if (normalizedLabel && reusedGateways.length > 1) {
return reusedGateways[0];
}
return null;
})()
);
};
const finalizeInstallerClaim = async ({ gatewayId, existingIds, message = "" }) => {
const normalizedGatewayId = normId(gatewayId);
if (!normalizedGatewayId) {
return false;
}
await refreshFleet({ forceRefresh: true });
flashMessage.value = message || (existingIds.has(Number(normalizedGatewayId)) ? "Gateway reconnected." : "Gateway connected.");
clearError();
await selectGateway(normalizedGatewayId, "overview");
return true;
};
const pollForClaimedGateway = async ({ claimTokenId, existingIds, baseline, departmentId, label, pollToken }) => {
let attempt = 0;
while (installerClaimPollToken.value === pollToken) {
if (attempt > 0) {
await sleep(attempt === 1 ? 1500 : 2000);
}
attempt += 1;
if (claimTokenId) {
try {
const response = await getEdgeGatewayInstallTokenStatus(claimTokenId);
installerSession.value = mergeInstallerSession(unwrap(response, null), installerSession.value);
} catch (error) {
const normalizedError = normalizeEdgeGatewayError(error);
installerSession.value = mergeInstallerSession(
{
last_error: normalizedError?.message || "Installer status could not be refreshed.",
diagnostics:
Array.isArray(normalizedError?.details) && normalizedError.details.length
? [{ name: "Status refresh", output: normalizedError.details.join("\n") }]
: installerSession.value?.diagnostics || [],
},
installerSession.value
);
}
}
if (String(installerSession.value?.status || "").toUpperCase() === "CLAIMED" && installerSession.value?.gateway_id) {
await finalizeInstallerClaim({
gatewayId: installerSession.value.gateway_id,
existingIds,
message: installerSession.value.message || "",
});
return;
}
await refreshFleet({ forceRefresh: true });
const claimedGateway = findClaimedGateway({ existingIds, baseline, departmentId, label });
if (claimedGateway) {
installerSession.value = mergeInstallerSession(
{
status: "CLAIMED",
step: "CLAIMED",
message: existingIds.has(Number(claimedGateway.id)) ? "Gateway reconnected." : "Gateway connected.",
terminal: true,
gateway_id: claimedGateway.id,
last_error: null,
diagnostics: [],
},
installerSession.value
);
await finalizeInstallerClaim({
gatewayId: claimedGateway.id,
existingIds,
message: installerSession.value?.message || "",
});
return;
}
if (isInstallerTerminal(installerSession.value)) {
return;
}
if (installerSession.value && Date.now() >= Number(installerSession.value.deadlineAt || 0)) {
installerSession.value = mergeInstallerSession(
{
status: "EXPIRED",
step: "EXPIRED",
message: "The installer token expired before the gateway claimed it.",
terminal: true,
},
installerSession.value
);
return;
}
}
};
const generateInstaller = async () => {
const departmentId = Number(installerDepartmentId.value || selectedDepartmentId.value || 0);
if (!departmentId) {
fail(new Error("Select a department before generating an installer."));
return;
}
loading.value.installer = true;
installerCopyState.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 payload = unwrap(response, null) || {};
installerClaimPollToken.value = pollToken;
installerCommand.value = payload.install_command || "";
installerSession.value = mergeInstallerSession(
{
claim_token_id: payload.claim_token_id ?? null,
department_id: payload.department_id ?? departmentId,
label: label || null,
expires_at: payload.expires_at ?? null,
status: "PENDING",
step: "PENDING",
message: "Installer command generated. Run it on the gateway host.",
terminal: false,
gateway_id: null,
last_error: null,
diagnostics: [],
events: [],
},
null
);
flashMessage.value = "Installer token generated.";
clearError();
pollForClaimedGateway({
claimTokenId: payload.claim_token_id ?? null,
existingIds,
baseline,
departmentId,
label,
pollToken,
}).catch(() => {});
} catch (error) {
fail(error);
} finally {
loading.value.installer = false;
}
};
const copyInstallerCommand = async () => {
if (!installerCommand.value) return;
try {
const copied = await copyPlainText(installerCommand.value);
if (!copied) {
throw new Error("COPY_UNAVAILABLE");
}
installerCopyState.value = "copied";
flashMessage.value = "Installer command copied.";
clearError();
} catch (_error) {
installerCopyState.value = "error";
fail(new Error("Could not copy the installer command."));
scheduleInstallerCopyReset();
return;
}
scheduleInstallerCopyReset();
};
const saveMetadata = async (payload) => {
if (!selectedGateway.value?.id) return;
loading.value.metadata = true;
try {
const response = await updateEdgeGateway(selectedGateway.value.id, payload);
const gateway = unwrap(response, null);
if (gateway) {
setSelectedGatewaySnapshot(gateway);
}
flashMessage.value = "Gateway metadata updated.";
clearError();
} catch (error) {
fail(error);
} finally {
loading.value.metadata = false;
}
};
const applyCutover = async (transportMode) => {
if (!selectedDepartmentId.value) return;
loading.value.cutover = true;
try {
await setDepartmentGatewayCutover(selectedDepartmentId.value, transportMode);
applyDepartmentTransportModeLocally(selectedDepartmentId.value, transportMode);
flashMessage.value = "Department cutover updated.";
clearError();
} catch (error) {
fail(error);
} finally {
loading.value.cutover = false;
}
};
const queueOperation = async ({ type, request }) => {
if (!selectedGateway.value?.id) return;
loading.value.operation = true;
try {
const response = await createEdgeGatewayOperation(selectedGateway.value.id, type, request || {});
const gateway = unwrap(response, null)?.gateway || response?.data?.data?.gateway || null;
if (gateway) {
setSelectedGatewaySnapshot(gateway);
}
flashMessage.value = `Gateway operation ${type} queued.`;
clearError();
refreshCurrentViewData(selectedGateway.value.id).catch(() => {});
} catch (error) {
fail(error);
} finally {
loading.value.operation = false;
}
};
const cancelOperation = async (operation) => {
if (!selectedGateway.value?.id || !operation?.id) return;
loading.value.operationCancel = true;
try {
const response = await cancelEdgeGatewayOperation(selectedGateway.value.id, operation.id);
const gateway = unwrap(response, null)?.gateway || response?.data?.data?.gateway || null;
if (gateway) {
setSelectedGatewaySnapshot(gateway);
}
flashMessage.value =
String(response?.data?.data?.operation?.status || "") === "CANCEL_REQUESTED"
? `Cancellation requested for ${operation.type}.`
: `${operation.type} cancelled.`;
clearError();
refreshCurrentViewData(selectedGateway.value.id).catch(() => {});
} catch (error) {
fail(error);
} finally {
loading.value.operationCancel = false;
}
};
const queueUpdate = async (targetVersion = updateTargetVersion.value) => {
const normalizedTargetVersion = String(targetVersion || "").trim();
updateTargetVersion.value = normalizedTargetVersion;
await queueOperation({
type: "UPDATE",
request: normalizedTargetVersion ? { target_version: normalizedTargetVersion } : {},
});
};
const setUpdateTargetVersion = (value) => {
updateTargetVersion.value = String(value || "");
};
const queueUninstall = async () => {
await queueOperation({ type: "UNINSTALL", request: {} });
};
const retryOperation = async (operation) => {
if (!operation?.type) {
return;
}
await queueOperation({
type: operation.type,
request: operation.request && typeof operation.request === "object" ? operation.request : {},
});
};
const selectOperationRecord = async (operation) => {
if (!operation?.id) {
selectedOperationId.value = null;
selectedOperationEvents.value = [];
return;
}
selectedOperationId.value = String(operation.id);
selectedOperationEvents.value = Array.isArray(operation.events) ? [...operation.events] : [];
if (!selectedGateway.value?.id) {
return;
}
try {
const response = await getEdgeGatewayOperationEvents(selectedGateway.value.id, operation.id);
selectedOperationEvents.value = Array.isArray(unwrap(response, [])) ? unwrap(response, []) : [];
} catch (_error) {
// Keep the embedded event list when the detail endpoint is unavailable.
}
};
const connectTerminal = async () => {
if (
!selectedGateway.value?.id ||
terminalBusy.value ||
terminalStatus.value === "open" ||
terminalStatus.value === "connecting"
) {
return;
}
closeTerminalClient();
const token = terminalSessionToken;
const readinessBlock = terminalBrokerReadinessBlock();
if (readinessBlock) {
terminalStatus.value = "blocked";
terminalError.value = readinessBlock.message;
terminalErrorDetails.value = readinessBlock.details;
terminalTranscript.value = "";
terminalSession.value = null;
terminalBusy.value = false;
return;
}
terminalBusy.value = true;
terminalError.value = "";
terminalErrorDetails.value = [];
terminalTranscript.value = "";
terminalSession.value = null;
terminalStatus.value = "connecting";
try {
const client = await createGatewayShellClient(
selectedGateway.value.id,
{
reason: "Interactive diagnostic terminal",
cwd: "/opt/truckwash-edge-agent",
cols: 120,
rows: 28,
},
{
onOpen: () => {
if (token !== terminalSessionToken) return;
terminalError.value = "";
terminalErrorDetails.value = [];
},
onMessage: (message = {}) => {
if (token !== terminalSessionToken) return;
if (message.type === "opened") {
terminalStatus.value = "open";
terminalBusy.value = false;
return;
}
if (message.type === "output" || message.type === "raw") {
appendTerminalOutput(message.data ?? "");
return;
}
if (message.type === "closed") {
applyTerminalClosed(message);
}
},
onError: (error) => {
if (token !== terminalSessionToken) return;
const normalizedClose = terminalCloseDiagnostics(error);
terminalStatus.value = "error";
terminalBusy.value = false;
terminalError.value = normalizedClose.message || error?.message || "Terminal connection failed.";
terminalErrorDetails.value = normalizedClose.details;
},
onClose: (closeEvent = {}) => {
if (token !== terminalSessionToken) return;
if (terminalStatus.value !== "closed" && terminalStatus.value !== "error") {
applyTerminalClosed(closeEvent);
return;
}
terminalBusy.value = false;
terminalClient = null;
},
}
);
if (token !== terminalSessionToken) {
client.close?.();
return;
}
terminalClient = client;
terminalSession.value = client.session;
terminalBusy.value = false;
} catch (error) {
if (token !== terminalSessionToken) {
return;
}
const normalizedError = normalizeEdgeGatewayError(error);
terminalStatus.value = "error";
terminalError.value = normalizedError?.message || "Could not open the terminal session.";
terminalErrorDetails.value = normalizedError?.details || [];
terminalBusy.value = false;
}
};
const disconnectTerminal = () => {
closeTerminalClient();
terminalStatus.value = "closed";
terminalError.value = "";
terminalErrorDetails.value = [];
terminalCommand.value = "";
terminalSession.value = null;
terminalBusy.value = false;
};
const setTerminalCommand = (value) => {
terminalCommand.value = String(value || "");
};
const sendTerminalCommand = (value = terminalCommand.value) => {
if (terminalStatus.value !== "open" || !terminalClient) {
return;
}
const commandText = String(value || "").trim();
if (!commandText) {
return;
}
try {
appendTerminalOutput(`${commandText}\n`);
terminalClient.sendInput(`${commandText}\n`);
terminalCommand.value = "";
} catch (error) {
const normalizedError = normalizeEdgeGatewayError(error);
terminalStatus.value = "error";
terminalError.value = normalizedError?.message || "Could not send the terminal command.";
terminalErrorDetails.value = normalizedError?.details || [];
}
};
const useTerminalShortcut = (command) => {
setTerminalCommand(command);
sendTerminalCommand(command);
};
const queueDiscovery = async () => {
const previousInventoryCount = selectedGateway.value?.inventory?.length || 0;
await queueOperation({ type: "DISCOVERY", request: {} });
if (!selectedGateway.value?.id) return;
loading.value.discovery = true;
try {
for (let attempt = 0; attempt < 4; attempt += 1) {
await sleep(750);
const refreshed = await refreshSelected(selectedGateway.value.id);
if ((refreshed?.inventory || []).length > previousInventoryCount) {
break;
}
}
} finally {
loading.value.discovery = false;
}
};
const addBindingRow = () => {
markBindingsDirty();
editableBindings.value.push({
relay_id: "",
device_id: "",
local_ip: "",
channel: 0,
fallback_mode: "PREFER_LOCAL",
binding_source: "MANUAL",
consumer_contexts: [],
});
};
const removeBindingRow = (index) => {
markBindingsDirty();
editableBindings.value = editableBindings.value.filter((_, itemIndex) => itemIndex !== index);
};
const updateBinding = (index, field, value) => {
markBindingsDirty();
editableBindings.value[index] = { ...editableBindings.value[index], [field]: value };
};
const saveBindingsAction = async () => {
if (!selectedGateway.value?.id) return;
loading.value.bindings = true;
try {
const response = await saveEdgeGatewayBindings(selectedGateway.value.id, editableBindings.value);
const gateway = unwrap(response, null);
if (gateway) {
bindingsDirty.value = false;
editableBindingsGatewayId.value = normId(gateway.id);
const mergedGateway = setSelectedGatewaySnapshot(gateway);
syncDrafts(mergedGateway || gateway, { force: true });
}
flashMessage.value = "Relay bindings saved.";
clearError();
} catch (error) {
fail(error);
} finally {
loading.value.bindings = false;
}
};
const rotateCredentials = async () => {
if (!selectedGateway.value?.id) return;
loading.value.rotate = true;
try {
const response = await rotateEdgeGatewayCredentials(selectedGateway.value.id);
rotateBundle.value = unwrap(response, null);
if (rotateBundle.value?.rotated_at) {
setSelectedGatewaySnapshot({
...selectedGateway.value,
metadata: {
...(selectedGateway.value?.metadata || {}),
credentials_rotated_at: rotateBundle.value.rotated_at,
},
credential_freshness: {
...(selectedGateway.value?.credential_freshness || {}),
rotated_at: rotateBundle.value.rotated_at,
age_days: 0,
state: "FRESH",
},
});
}
refreshSelected(selectedGateway.value.id).catch(() => {});
flashMessage.value = "Gateway credentials rotated.";
clearError();
} catch (error) {
fail(error);
} finally {
loading.value.rotate = false;
}
};
const removeGateway = async () => {
if (!selectedGateway.value?.id) return;
loading.value.delete = true;
try {
const deletedId = Number(selectedGateway.value.id);
await deleteEdgeGateway(deletedId);
gateways.value = gateways.value.filter((gateway) => Number(gateway.id) !== deletedId);
fleetUsage.value = buildFleetUsageFromRows(gateways.value);
clearSelection();
rotateBundle.value = null;
flashMessage.value = "Gateway deleted.";
clearError();
if (props.routeDriven) {
emit("navigate", { gatewayId: null, view: "overview" });
} else if (gateways.value.length) {
await refreshSelected(gateways.value[0].id);
}
} catch (error) {
fail(error);
} finally {
loading.value.delete = false;
}
};
const setView = (viewId) => {
const normalizedViewId = normalizeAvailableViewId(viewId);
if (props.routeDriven && selectedGateway.value?.id) {
emit("navigate", { gatewayId: String(selectedGateway.value.id), view: normalizedViewId });
return;
}
localView.value = normalizedViewId;
};
const stopAutoRefresh = () => {
if (autoRefreshHandle !== null) {
window.clearInterval(autoRefreshHandle);
autoRefreshHandle = null;
}
};
const autoRefreshTick = async () => {
if (
loading.value.init ||
loading.value.detail ||
loading.value.operation ||
loading.value.operationCancel ||
loading.value.discovery
) {
return;
}
if (selectedGateway.value?.id) {
const shouldRefreshAuxiliaryView = ["tasks", "logs", "statistics"].includes(currentView.value);
const shouldRefreshDetail =
currentView.value === "overview" ||
currentView.value === "inventory" ||
currentView.value === "settings" ||
currentView.value === "terminal" ||
Boolean(selectedGateway.value.active_operation);
const refreshes = [];
if (shouldRefreshAuxiliaryView) {
refreshes.push(refreshCurrentViewData(selectedGateway.value.id));
} else if (shouldRefreshDetail) {
refreshes.push(refreshSelected(selectedGateway.value.id));
}
if (fleetRefreshDue()) {
refreshes.push(refreshFleet());
}
if (refreshes.length) {
await Promise.all(refreshes);
}
return;
}
if (fleetRefreshDue(5000)) {
await refreshFleet();
}
};
const startAutoRefresh = () => {
stopAutoRefresh();
autoRefreshHandle = window.setInterval(() => {
autoRefreshTick().catch(() => {});
}, 5000);
};
watch(
() => props.departmentId,
(value) => {
installerDepartmentId.value = value ? String(value) : "";
load().catch(() => {});
},
{ immediate: true }
);
watch(
() => props.selectedGatewayId,
() => {
syncSelection().catch(() => {});
}
);
watch(
() => props.activeView,
(value) => {
if (!props.routeDriven && value) {
localView.value = normalizeViewId(value);
}
},
{ immediate: true }
);
watch(
() => activeGatewayId.value,
(value, previousValue) => {
if (String(value || "") && String(value || "") !== String(previousValue || "")) {
resetGatewayViewSnapshots();
}
startGatewayStream(value).catch(() => {});
}
);
watch(
[() => activeGatewayId.value, currentView],
([gatewayId, viewId]) => {
const normalizedViewId = String(viewId || "");
if (!gatewayId) {
return;
}
if (normalizedViewId === "terminal") {
if (!terminalSession.value && !terminalBusy.value && terminalStatus.value !== "open") {
connectTerminal().catch(() => {});
}
return;
}
if (!["tasks", "logs", "statistics"].includes(normalizedViewId)) {
return;
}
refreshCurrentViewData(gatewayId).catch(() => {});
},
{ immediate: true }
);
watch(
taskOperations,
(operations) => {
syncSelectedOperation(operations);
},
{ immediate: true }
);
watch(installerCommand, () => {
installerCopyState.value = "idle";
});
onMounted(() => {
if (props.departmentId) {
installerDepartmentId.value = String(props.departmentId);
}
startAutoRefresh();
});
onUnmounted(() => {
stopAutoRefresh();
closeGatewayStream();
closeTerminalClient();
});
</script>
<template>
<section class="edge-v2-shell" data-testid="edge-gateway-workspace">
<div v-if="flashMessage" class="notification is-success is-light">{{ flashMessage }}</div>
<div v-if="errorState" class="notification is-danger is-light edge-error-banner" data-testid="gateway-error-banner">
<strong>{{ errorState.title }}</strong>
<p>{{ errorState.description }}</p>
<p>{{ errorState.message }}</p>
<div v-if="errorState.details?.length" class="edge-error-actions">
<details data-testid="gateway-error-details">
<summary>Technical details</summary>
<pre class="edge-json-block">{{ errorDetailsText }}</pre>
</details>
<button type="button" class="button is-small is-light" data-testid="gateway-error-copy" @click="copyErrorDetails">
Copy details
</button>
</div>
</div>
<div class="edge-summary-strip" data-testid="gateway-summary-strip">
<button
v-for="summary in summaryCards"
:key="summary.key"
type="button"
class="button is-light"
:data-testid="`gateway-summary-${summary.key.toLowerCase()}`"
@click="fleetFilter = summary.key"
>
{{ summary.label }} · {{ summary.count }}
</button>
</div>
<div class="edge-v2-layout">
<aside class="edge-v2-rail">
<div class="edge-rail-card">
<label class="label" for="gateway-fleet-search">Search fleet</label>
<input
id="gateway-fleet-search"
v-model="fleetQuery"
class="input"
data-testid="gateway-fleet-search"
placeholder="Gateway, department, host, or action"
/>
</div>
<div class="edge-rail-card" data-testid="gateway-fleet-roster">
<div class="edge-rail-head">
<h2 class="title is-6">Fleet</h2>
<span class="tag is-light">{{ filteredGatewayViews.length }}</span>
</div>
<button
v-for="gateway in filteredGatewayViews"
:key="gateway.id"
type="button"
class="edge-fleet-item"
:class="{ 'is-active': String(gateway.id) === String(activeGatewayId) }"
:data-testid="`gateway-fleet-item-${gateway.id}`"
@click="selectGateway(gateway.id, currentView || 'overview')"
>
<div class="edge-operation-title-row">
<strong>{{ gateway.displayLabel }}</strong>
<span class="tag is-light" :class="`is-${gateway.statusTone}`">{{ gateway.statusLabel }}</span>
</div>
<span>{{ gateway.departmentName }}</span>
<div class="edge-fleet-meta">
<small>{{ gateway.primaryAction }}</small>
<small v-if="gateway.active_operation">
{{ gateway.active_operation.type }} · {{ gateway.active_operation.summary?.progress || 0 }}%
</small>
</div>
</button>
<div v-if="!filteredGatewayViews.length" class="has-text-grey" data-testid="gateway-empty-state">
No gateways match the current filter.
</div>
</div>
</aside>
<main class="edge-v2-main">
<section v-if="loading.init" class="edge-state-card">Loading gateway workspace...</section>
<section v-else-if="unavailableGatewayId" class="edge-state-card" data-testid="gateway-unavailable-state">
Gateway {{ unavailableGatewayId }} could not be loaded.
</section>
<section v-else-if="showFleetLanding" class="edge-state-card" data-testid="gateway-fleet-landing">
<div class="edge-page-header">
<div>
<p class="edge-page-kicker">Fleet landing</p>
<h2 class="title is-4">Edge Gateways v2</h2>
<p class="has-text-grey">Select a gateway from the rail or onboard a new PHP gateway for management v2.</p>
</div>
</div>
<div class="edge-overview-grid" data-testid="gateway-fleet-usage">
<article
v-for="card in fleetUsageCards"
:key="card.key"
class="edge-stat-card"
:data-testid="`gateway-fleet-usage-${card.key}`"
>
<p class="edge-card-label">{{ card.label }}</p>
<strong>{{ card.value }}</strong>
<p>{{ card.detail }}</p>
</article>
</div>
<div v-if="allowDestructive" class="edge-onboarding-card" data-testid="gateway-onboarding">
<div class="edge-panel-head">
<h3 class="title is-6">Install new gateway</h3>
</div>
<div class="edge-inline-form">
<select v-model="installerDepartmentId" class="input" data-testid="gateway-installer-department">
<option value="">Select department</option>
<option v-for="department in departments" :key="department.id" :value="String(department.id)">
{{ department.name }}
</option>
</select>
<input v-model="installerLabel" class="input" data-testid="gateway-installer-label" placeholder="Gateway label" />
<button
type="button"
class="button is-primary"
data-testid="gateway-installer-generate"
:class="{ 'is-loading': loading.installer }"
@click="generateInstaller"
>
Generate installer
</button>
<button
v-if="installerCommand"
type="button"
class="button is-light"
data-testid="gateway-installer-copy"
@click="copyInstallerCommand"
>
{{ installerCopyLabel }}
</button>
</div>
<textarea
v-if="installerCommand"
:value="installerCommand"
class="textarea"
readonly
rows="4"
data-testid="gateway-install-command"
></textarea>
<article v-if="installerSessionView" class="edge-installer-status" data-testid="gateway-installer-status">
<div class="edge-operation-title-row">
<strong>Installer session</strong>
<span
class="tag is-light"
:class="`is-${installerSessionView.stateTone}`"
data-testid="gateway-installer-status-state"
>
{{ installerSessionView.stateLabel }}
</span>
</div>
<p class="edge-card-label" data-testid="gateway-installer-status-step">{{ installerSessionView.stepLabel }}</p>
<p>{{ installerSessionView.message }}</p>
<p v-if="installerSessionView.lastError" class="has-text-danger" data-testid="gateway-installer-status-error">
{{ installerSessionView.lastError }}
</p>
<details
v-if="installerSessionView.diagnostics?.length"
class="edge-diagnostics"
data-testid="gateway-installer-status-diagnostics"
>
<summary>Diagnostics</summary>
<article
v-for="(diagnostic, index) in installerSessionView.diagnostics"
:key="`${diagnostic.name || diagnostic.label || 'diagnostic'}-${index}`"
class="edge-diagnostic-item"
>
<strong>{{ diagnostic.name || diagnostic.label || `Diagnostic ${index + 1}` }}</strong>
<pre class="edge-json-block">{{ diagnostic.output || diagnostic.message || diagnostic.detail || "" }}</pre>
</article>
</details>
</article>
</div>
</section>
<section v-else-if="selectedGatewayView" class="edge-detail-shell">
<header class="edge-panel">
<div class="edge-page-header">
<div>
<p class="edge-page-kicker">Selected gateway</p>
<h2 class="title is-4" data-testid="gateway-detail-header">{{ selectedGatewayView.displayLabel }}</h2>
<p class="edge-page-subtitle">
{{ selectedGatewayView.departmentName }} · {{ selectedGatewayView.hostname || "No hostname" }} ·
{{ selectedGatewayView.statusLabel }}
</p>
</div>
<div class="edge-action-group">
<span class="tag is-light">{{ selectedGatewayView.discoveryStatusLabel }}</span>
<span class="tag is-light">{{ selectedGatewayView.departmentTransportModeLabel }}</span>
</div>
</div>
</header>
<nav class="tabs is-boxed edge-tab-nav" data-testid="gateway-tab-nav">
<ul>
<li v-for="tab in tabs" :key="tab.id" :class="{ 'is-active': currentView === tab.id }">
<button type="button" class="edge-tab-button" :data-testid="`gateway-tab-${tab.id}`" @click="setView(tab.id)">
{{ tab.label }}
</button>
</li>
</ul>
</nav>
<EdgeGatewayOverviewPage
v-if="currentView === 'overview'"
:gateway="selectedGatewayView"
:show-open-full-page="Boolean(props.departmentId)"
@open-full-page="emit('open-gateway-page', selectedGatewayView.id)"
/>
<EdgeGatewayInventoryPage
v-else-if="currentView === 'inventory'"
:gateway="selectedGatewayView"
:bindings="editableBindings"
:busy="loading.discovery || loading.operation || loading.bindings"
@trigger-discovery="queueDiscovery"
@add-binding="addBindingRow"
@remove-binding="removeBindingRow"
@update-binding="updateBinding"
@save-bindings="saveBindingsAction"
/>
<EdgeGatewayTasksPage
v-else-if="currentView === 'tasks'"
:operations="taskOperations"
:recent-commands="taskRecentCommands"
:operation-summary="taskOperationSummary"
:selected-operation-id="selectedOperationId"
:operation-events="selectedOperationEvents"
:allow-destructive="allowDestructive"
:busy="loading.operation || loading.operationCancel"
:update-target-version="taskUpdateTargetVersion"
:stream-status="gatewayStreamStatus"
:stream-error="gatewayStreamError"
@queue-discovery="queueDiscovery"
@queue-update="queueUpdate"
@queue-uninstall="queueUninstall"
@cancel-operation="cancelOperation"
@retry-operation="retryOperation"
@select-operation="selectOperationRecord"
@update-target-version="setUpdateTargetVersion"
/>
<EdgeGatewayLogsPage
v-else-if="currentView === 'logs'"
:timeline="logTimeline"
:relay-logs="logRelayLogs"
:shell-sessions="logShellSessions"
:stream-status="gatewayStreamStatus"
:stream-error="gatewayStreamError"
/>
<EdgeGatewayStatisticsPage
v-else-if="currentView === 'statistics'"
:statistics="statisticsPageState"
:stream-status="gatewayStreamStatus"
:stream-error="gatewayStreamError"
/>
<EdgeGatewayTerminalPage
v-else-if="currentView === 'terminal'"
:status="terminalStatus"
:error="terminalError"
:diagnostics="terminalErrorDetails"
:transcript="terminalTranscript"
:command="terminalCommand"
:session="terminalSession"
:busy="terminalBusy"
@connect="connectTerminal"
@disconnect="disconnectTerminal"
@update-command="setTerminalCommand"
@send-command="sendTerminalCommand"
@shortcut="useTerminalShortcut"
/>
<EdgeGatewayManagePage
v-else-if="currentView === 'settings' && allowDestructive"
:gateway="selectedGatewayView"
:loading-metadata="loading.metadata"
:loading-cutover="loading.cutover"
:loading-rotate="loading.rotate"
:loading-delete="loading.delete"
:rotate-bundle="rotateBundle"
@save-metadata="saveMetadata"
@apply-cutover="applyCutover"
@rotate-credentials="rotateCredentials"
@delete-gateway="removeGateway"
/>
</section>
</main>
</div>
</section>
</template>
<style scoped>
.edge-v2-shell {
display: grid;
gap: 1rem;
}
.edge-summary-strip {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.edge-v2-layout {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
gap: 1rem;
}
.edge-v2-rail,
.edge-v2-main,
.edge-page,
.edge-state-card,
.edge-onboarding-card,
.edge-installer-status,
.edge-detail-shell,
.edge-error-banner,
.edge-error-actions,
.edge-fleet-meta,
.edge-progress-block,
.edge-detail-grid,
.edge-detail-card,
.edge-operation-detail {
display: grid;
gap: 1rem;
}
.edge-rail-card,
.edge-state-card,
.edge-onboarding-card,
.edge-panel,
.edge-stat-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 249, 252, 0.98));
border: 1px solid rgba(32, 41, 58, 0.08);
border-radius: 18px;
box-shadow: 0 14px 35px rgba(32, 41, 58, 0.06);
padding: 1rem;
}
.edge-rail-head,
.edge-panel-head,
.edge-page-header,
.edge-operation-title-row,
.edge-progress-meta {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.edge-page-kicker,
.edge-card-label {
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6a7a90;
}
.edge-page-subtitle {
color: #64748b;
}
.edge-fleet-item,
.edge-operation-item,
.edge-tab-button {
width: 100%;
border: 0;
background: transparent;
text-align: left;
}
.edge-fleet-item,
.edge-operation-item {
display: grid;
gap: 0.35rem;
padding: 0.85rem;
border-radius: 14px;
border: 1px solid rgba(32, 41, 58, 0.08);
margin-bottom: 0.65rem;
}
.edge-fleet-item.is-active,
.edge-operation-item.is-active {
border-color: rgba(15, 118, 110, 0.35);
background: rgba(15, 118, 110, 0.06);
}
.edge-overview-grid,
.edge-page-columns,
.edge-inline-form {
display: grid;
gap: 1rem;
}
.edge-overview-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.edge-page-columns,
.edge-detail-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.edge-inline-form {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.edge-diagnostics,
.edge-audit-list,
.edge-event-list,
.edge-binding-list,
.edge-operation-list {
display: grid;
gap: 0.75rem;
}
.edge-diagnostic-item,
.edge-audit-item,
.edge-event-item,
.edge-binding-row,
.edge-detail-card {
display: grid;
gap: 0.65rem;
padding: 0.8rem;
border: 1px solid rgba(32, 41, 58, 0.08);
border-radius: 12px;
}
.edge-binding-row {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
align-items: center;
}
.edge-checkbox {
margin: 0.75rem 0;
}
.edge-tab-nav .edge-tab-button {
padding: 0.85rem 1rem;
}
.edge-action-group {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.edge-banner {
margin: 0;
}
.edge-installer-status {
padding: 0.9rem;
border-radius: 14px;
border: 1px solid rgba(32, 41, 58, 0.08);
background: rgba(248, 250, 252, 0.88);
}
.edge-credential-bundle {
display: grid;
gap: 0.75rem;
}
.edge-json-block {
margin: 0;
padding: 0.75rem;
border-radius: 12px;
background: rgba(15, 23, 42, 0.06);
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 960px) {
.edge-v2-layout {
grid-template-columns: 1fr;
}
}
</style>