2806 lines
88 KiB
Vue
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>
|