1255 lines
36 KiB
Vue
1255 lines
36 KiB
Vue
<script setup>
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
import { RouterLink } from "vue-router";
|
|
import { useI18n } from "vue-i18n";
|
|
import {
|
|
SuperUserSystemStatusObject,
|
|
getSuperuserSystemStatus,
|
|
} from "@/components/session/token/superUser/systemStatus.vue";
|
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
import {
|
|
listEdgeGatewayDepartments,
|
|
listEdgeGateways,
|
|
unwrapEdgeGatewayMeta,
|
|
} from "@/services/edgeGateways.js";
|
|
|
|
const { t, te, locale } = useI18n();
|
|
|
|
const MAX_GATEWAY_CARDS = 8;
|
|
const DEFAULT_DASHBOARD_TAB = "infrastructure";
|
|
const gatewayStatusPriority = Object.freeze({
|
|
OFFLINE: 0,
|
|
DEGRADED: 1,
|
|
ONLINE: 2,
|
|
});
|
|
|
|
const nowTick = ref(Date.now());
|
|
let pollTimeout = null;
|
|
let clockInterval = null;
|
|
|
|
const moduleConfigPaths = {
|
|
economic: "/superuser/configuration/economic",
|
|
reCAPTCHA: "/superuser/configuration/recaptcha",
|
|
email: "/superuser/configuration/email",
|
|
backups: "/superuser/configuration/backup",
|
|
motorapi: "/superuser/configuration/motorapi",
|
|
stripe: "/superuser/configuration/stripe",
|
|
fxratesapi: "/superuser/configuration/fxratesapi",
|
|
weatherapi: "/superuser/configuration/weatherapi",
|
|
workfeed: "/superuser/configuration/workfeed",
|
|
gatewayapi: "/superuser/configuration/gatewayapi",
|
|
xlvask: "/superuser/configuration/xlvask",
|
|
entra: "/superuser/configuration/entra",
|
|
limble: "/superuser/configuration/limble",
|
|
ocrspace: "/superuser/configuration/ocrspace",
|
|
openai: "/superuser/configuration/openai",
|
|
licenseplaterecognizer: "/superuser/configuration/licenseplaterecognizer",
|
|
virkdata: "/superuser/configuration/virkdata",
|
|
shelly: "/superuser/configuration/shelly",
|
|
selfserve: "/superuser/configuration/selfserve",
|
|
bird: "/superuser/configuration/bird",
|
|
coolify: "/superuser/configuration/coolify",
|
|
};
|
|
|
|
const snapshot = computed(() => SuperUserSystemStatusObject.snapshot.value);
|
|
const runtime = computed(() => SuperUserSystemStatusObject.runtime.value);
|
|
const dependencies = computed(() => SuperUserSystemStatusObject.dependencies.value);
|
|
const modules = computed(() => SuperUserSystemStatusObject.modules.value);
|
|
const sessions = computed(() => SuperUserSystemStatusObject.sessions.value);
|
|
const warnings = computed(() => SuperUserSystemStatusObject.warnings.value);
|
|
const warningEntries = computed(() => {
|
|
const entries = snapshot.value?.warning_entries;
|
|
if (Array.isArray(entries) && entries.length > 0) {
|
|
return entries;
|
|
}
|
|
return warnings.value.map((warning) => ({ key: null, params: {}, message: warning }));
|
|
});
|
|
const loading = computed(() => SuperUserSystemStatusObject.loading.value);
|
|
const error = computed(() => SuperUserSystemStatusObject.error.value);
|
|
const lastLoadedAt = computed(() => SuperUserSystemStatusObject.lastLoadedAt.value);
|
|
const refreshAfterSeconds = computed(() => SuperUserSystemStatusObject.refreshAfterSeconds.value || 30);
|
|
const overallStatus = computed(() => SuperUserSystemStatusObject.overallStatus.value);
|
|
const canViewGateways = computed(() => SessionUser.hasPermission("modules_shelly_config"));
|
|
|
|
const gatewayLoading = ref(false);
|
|
const gatewayError = ref(null);
|
|
const gatewayRows = ref([]);
|
|
const gatewayFleetUsage = ref(createEmptyGatewayFleetUsage());
|
|
const gatewaySectionSuppressed = ref(false);
|
|
const gatewayDepartments = ref({});
|
|
const gatewayDepartmentsLoaded = ref(false);
|
|
const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB);
|
|
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
|
|
const dashboardTabKeys = computed(() => [
|
|
DEFAULT_DASHBOARD_TAB,
|
|
...(showGatewaySection.value ? ["gateways"] : []),
|
|
"modules",
|
|
"sessions",
|
|
]);
|
|
const gatewaySummaryCards = computed(() => [
|
|
{
|
|
id: "total",
|
|
title: t("system_status.gateways.summary.total"),
|
|
value: gatewayFleetUsage.value.total,
|
|
toneClass: null,
|
|
},
|
|
{
|
|
id: "online",
|
|
title: t("system_status.gateways.summary.online"),
|
|
value: gatewayFleetUsage.value.online,
|
|
toneClass: "is-ok",
|
|
},
|
|
{
|
|
id: "degraded",
|
|
title: t("system_status.gateways.summary.degraded"),
|
|
value: gatewayFleetUsage.value.degraded,
|
|
toneClass: "is-degraded",
|
|
},
|
|
{
|
|
id: "offline",
|
|
title: t("system_status.gateways.summary.offline"),
|
|
value: gatewayFleetUsage.value.offline,
|
|
toneClass: "is-down",
|
|
},
|
|
]);
|
|
const gatewayCards = computed(() => {
|
|
return [...gatewayRows.value]
|
|
.sort(compareGateways)
|
|
.slice(0, MAX_GATEWAY_CARDS)
|
|
.map((gateway) => {
|
|
const activeOperation = gateway?.active_operation || null;
|
|
return {
|
|
id: gateway?.id ?? null,
|
|
displayLabel: gatewayDisplayLabel(gateway),
|
|
departmentName: gatewayDepartmentName(gateway),
|
|
toneClass: gatewayToneClass(gateway?.status),
|
|
statusLabel: gatewayStatusLabel(gateway?.status),
|
|
discoveryLabel: gatewayDiscoveryLabel(gateway?.discovery_status),
|
|
lastHeartbeatLabel: formatDate(gateway?.last_heartbeat_at),
|
|
activeOperationLabel: gatewayOperationLabel(activeOperation),
|
|
message: gatewayPrimaryMessage(gateway),
|
|
link: `/superuser/selfserve/edge-agents/${encodeURIComponent(String(gateway?.id ?? ""))}/overview`,
|
|
};
|
|
})
|
|
.filter((gateway) => gateway.id !== null);
|
|
});
|
|
|
|
const statusCards = computed(() => [
|
|
{
|
|
id: "database",
|
|
title: t("system_status.cards.database"),
|
|
status: dependencies.value?.database?.status,
|
|
primary: dependencies.value?.database?.database || "MySQL",
|
|
secondary: dependencies.value?.database?.server_version || "--",
|
|
detail: formatLatency(dependencies.value?.database?.latency_ms),
|
|
replicationLabel: formatReplicationLabel(dependencies.value?.database?.replication),
|
|
},
|
|
{
|
|
id: "redis",
|
|
title: t("system_status.cards.redis"),
|
|
status: dependencies.value?.redis?.status,
|
|
primary: dependencies.value?.redis?.database ?? "--",
|
|
secondary: t("system_status.labels.database_index"),
|
|
detail: formatLatency(dependencies.value?.redis?.latency_ms),
|
|
replicationLabel: formatReplicationLabel(dependencies.value?.redis?.replication),
|
|
},
|
|
{
|
|
id: "minio",
|
|
title: t("system_status.cards.minio"),
|
|
status: dependencies.value?.minio?.status,
|
|
primary: dependencies.value?.minio?.endpoint || "--",
|
|
secondary: formatMinioBuckets(dependencies.value?.minio?.buckets),
|
|
detail: formatLatency(dependencies.value?.minio?.latency_ms),
|
|
replicationLabel: formatReplicationLabel(dependencies.value?.minio?.replication),
|
|
},
|
|
{
|
|
id: "cpu",
|
|
title: t("system_status.cards.cpu"),
|
|
status: runtime.value?.cpu?.status,
|
|
primary: formatUsage(runtime.value?.cpu?.usage_percent),
|
|
secondary: runtime.value?.cpu?.source || "--",
|
|
detail: t("system_status.labels.runtime_source"),
|
|
},
|
|
{
|
|
id: "memory",
|
|
title: t("system_status.cards.memory"),
|
|
status: runtime.value?.memory?.status,
|
|
primary: formatUsage(runtime.value?.memory?.usage_percent),
|
|
secondary: formatBytes(runtime.value?.memory?.used_bytes) + " / " + formatBytes(runtime.value?.memory?.total_bytes),
|
|
detail: runtime.value?.memory?.source || "--",
|
|
},
|
|
{
|
|
id: "disk",
|
|
title: t("system_status.cards.disk"),
|
|
status: runtime.value?.disk?.status,
|
|
primary: formatUsage(runtime.value?.disk?.usage_percent),
|
|
secondary: formatBytes(runtime.value?.disk?.used_bytes) + " / " + formatBytes(runtime.value?.disk?.total_bytes),
|
|
detail: runtime.value?.disk?.path || "--",
|
|
},
|
|
]);
|
|
|
|
const isStale = computed(() => {
|
|
if (!lastLoadedAt.value) {
|
|
return false;
|
|
}
|
|
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000);
|
|
});
|
|
|
|
watch(
|
|
() => [activeDashboardTab.value, dashboardTabKeys.value.join("|")],
|
|
() => {
|
|
if (!dashboardTabKeys.value.includes(activeDashboardTab.value)) {
|
|
activeDashboardTab.value = DEFAULT_DASHBOARD_TAB;
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
const loadStatus = async ({ force = false } = {}) => {
|
|
const [snapshotValue] = await Promise.all([
|
|
getSuperuserSystemStatus({ force }),
|
|
loadGatewayFleet({ force }),
|
|
]);
|
|
|
|
return snapshotValue;
|
|
};
|
|
|
|
const clearPolling = () => {
|
|
if (pollTimeout) {
|
|
clearTimeout(pollTimeout);
|
|
pollTimeout = null;
|
|
}
|
|
};
|
|
|
|
const schedulePolling = () => {
|
|
clearPolling();
|
|
if (document.hidden) {
|
|
return;
|
|
}
|
|
|
|
pollTimeout = setTimeout(async () => {
|
|
try {
|
|
await loadStatus();
|
|
} finally {
|
|
schedulePolling();
|
|
}
|
|
}, refreshAfterSeconds.value * 1000);
|
|
};
|
|
|
|
const refreshNow = async () => {
|
|
try {
|
|
await loadStatus({ force: true });
|
|
} finally {
|
|
schedulePolling();
|
|
}
|
|
};
|
|
|
|
const onVisibilityChange = async () => {
|
|
if (document.hidden) {
|
|
clearPolling();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await loadStatus();
|
|
} finally {
|
|
schedulePolling();
|
|
}
|
|
};
|
|
|
|
onMounted(async () => {
|
|
clockInterval = setInterval(() => {
|
|
nowTick.value = Date.now();
|
|
}, 1000);
|
|
|
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
|
|
try {
|
|
await loadStatus();
|
|
} finally {
|
|
schedulePolling();
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
clearPolling();
|
|
if (clockInterval) {
|
|
clearInterval(clockInterval);
|
|
}
|
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
});
|
|
|
|
function statusClass(status) {
|
|
return {
|
|
"is-ok": status === "ok" || status === "configured",
|
|
"is-degraded": status === "degraded" || status === "not_configured",
|
|
"is-down": status === "down" || status === "disabled",
|
|
};
|
|
}
|
|
|
|
function statusLabel(status) {
|
|
return t(`system_status.status.${status || "unknown"}`);
|
|
}
|
|
|
|
function translateSystemStatusText(section, key, params = {}, fallback = null) {
|
|
if (!key) {
|
|
return fallback;
|
|
}
|
|
const normalizedParams = normalizeTranslationParams(params);
|
|
const translationKey = `system_status.${section}.${key}`;
|
|
if (te(translationKey)) {
|
|
return t(translationKey, normalizedParams);
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeTranslationParams(params = {}) {
|
|
const normalized = { ...params };
|
|
|
|
if (Array.isArray(normalized.variables_list) && !normalized.variables) {
|
|
normalized.variables = normalized.variables_list.join(", ");
|
|
}
|
|
|
|
if (Array.isArray(normalized.module_keys)) {
|
|
normalized.modules = normalized.module_keys.map((moduleKey) => moduleLabel(moduleKey)).join(", ");
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function moduleLabel(key) {
|
|
return translateSystemStatusText("modules", key, {}, key) || key;
|
|
}
|
|
|
|
function moduleReasonText(module) {
|
|
return translateSystemStatusText(
|
|
"reasons",
|
|
module?.status_reason_key,
|
|
module?.status_reason_params,
|
|
module?.status_reason || t("system_status.labels.no_reason")
|
|
);
|
|
}
|
|
|
|
function createEmptyGatewayFleetUsage() {
|
|
return {
|
|
total: 0,
|
|
online: 0,
|
|
degraded: 0,
|
|
offline: 0,
|
|
};
|
|
}
|
|
|
|
function resetGatewayState({ suppress = false } = {}) {
|
|
gatewayRows.value = [];
|
|
gatewayFleetUsage.value = createEmptyGatewayFleetUsage();
|
|
gatewayError.value = null;
|
|
gatewaySectionSuppressed.value = suppress;
|
|
}
|
|
|
|
async function loadGatewayFleet({ force = false } = {}) {
|
|
if (!canViewGateways.value) {
|
|
resetGatewayState();
|
|
return [];
|
|
}
|
|
|
|
gatewayLoading.value = true;
|
|
gatewayError.value = null;
|
|
|
|
try {
|
|
const response = await listEdgeGateways({ view: "summary", forceRefresh: force });
|
|
const rows = Array.isArray(response?.data?.data) ? response.data.data : [];
|
|
const fleetUsage = unwrapEdgeGatewayMeta(response)?.fleet_usage;
|
|
|
|
gatewayRows.value = rows;
|
|
gatewayFleetUsage.value = normalizeGatewayFleetUsage(rows, fleetUsage);
|
|
gatewaySectionSuppressed.value = false;
|
|
|
|
await ensureGatewayDepartmentsLoaded();
|
|
|
|
return rows;
|
|
} catch (requestError) {
|
|
const statusCode = Number(requestError?.response?.status ?? 0);
|
|
if (statusCode === 403) {
|
|
resetGatewayState({ suppress: true });
|
|
return [];
|
|
}
|
|
|
|
gatewayError.value = requestError;
|
|
gatewaySectionSuppressed.value = false;
|
|
return gatewayRows.value;
|
|
} finally {
|
|
gatewayLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function ensureGatewayDepartmentsLoaded() {
|
|
if (gatewayDepartmentsLoaded.value) {
|
|
return gatewayDepartments.value;
|
|
}
|
|
|
|
try {
|
|
const response = await listEdgeGatewayDepartments();
|
|
const rows = Array.isArray(response?.data?.data) ? response.data.data : [];
|
|
|
|
gatewayDepartments.value = rows.reduce((lookup, department) => {
|
|
const departmentId = Number(department?.id ?? 0);
|
|
const name = String(department?.name || department?.label || department?.title || "").trim();
|
|
if (departmentId > 0 && name !== "") {
|
|
lookup[departmentId] = name;
|
|
}
|
|
return lookup;
|
|
}, {});
|
|
gatewayDepartmentsLoaded.value = true;
|
|
} catch (_error) {
|
|
gatewayDepartmentsLoaded.value = false;
|
|
}
|
|
|
|
return gatewayDepartments.value;
|
|
}
|
|
|
|
function warningText(warning) {
|
|
return translateSystemStatusText(
|
|
"warnings",
|
|
warning?.key,
|
|
warning?.params,
|
|
warning?.message || ""
|
|
);
|
|
}
|
|
|
|
function sessionDisplayName(session) {
|
|
return translateSystemStatusText(
|
|
"session_display",
|
|
session?.display_name_key,
|
|
session?.display_name_params,
|
|
session?.display_name || "--"
|
|
);
|
|
}
|
|
|
|
function sessionContextLabel(session) {
|
|
return translateSystemStatusText(
|
|
"session_context",
|
|
session?.context_label_key,
|
|
session?.context_label_params,
|
|
session?.context_label || "--"
|
|
);
|
|
}
|
|
|
|
function formatSessionKind(sessionKind) {
|
|
return translateSystemStatusText("session_types", sessionKind, {}, sessionKind || t("system_status.status.unknown"));
|
|
}
|
|
|
|
function formatDeviceType(deviceType) {
|
|
return translateSystemStatusText("devices", deviceType, {}, deviceType || t("system_status.status.unknown"));
|
|
}
|
|
|
|
function normalizeGatewayStatus(status) {
|
|
const normalizedStatus = String(status || "").trim().toUpperCase();
|
|
return normalizedStatus || "UNKNOWN";
|
|
}
|
|
|
|
function gatewayToneClass(status) {
|
|
switch (normalizeGatewayStatus(status)) {
|
|
case "ONLINE":
|
|
return "is-ok";
|
|
case "DEGRADED":
|
|
return "is-degraded";
|
|
default:
|
|
return "is-down";
|
|
}
|
|
}
|
|
|
|
function gatewayStatusLabel(status) {
|
|
const normalizedStatus = normalizeGatewayStatus(status).toLowerCase();
|
|
return translateSystemStatusText(
|
|
"gateways.status",
|
|
normalizedStatus,
|
|
{},
|
|
t("system_status.gateways.status.unknown")
|
|
);
|
|
}
|
|
|
|
function gatewayDiscoveryLabel(status) {
|
|
const normalizedStatus = String(status || "unknown").trim().toLowerCase();
|
|
return translateSystemStatusText(
|
|
"gateways.discovery_status",
|
|
normalizedStatus,
|
|
{},
|
|
t("system_status.gateways.discovery_status.unknown")
|
|
);
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) {
|
|
return "--";
|
|
}
|
|
return new Intl.DateTimeFormat(locale.value || "da", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function formatLatency(value) {
|
|
if (value === null || value === undefined) {
|
|
return "--";
|
|
}
|
|
return `${Number(value).toFixed(1)} ms`;
|
|
}
|
|
|
|
function formatUsage(value) {
|
|
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
|
return "--";
|
|
}
|
|
return `${Number(value).toFixed(1)}%`;
|
|
}
|
|
|
|
function formatReplicationLabel(replication) {
|
|
if (!replication || typeof replication !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const percent = Number(replication.average_percent ?? replication.min_percent ?? 0);
|
|
const status = statusLabel(replication.status || "unknown");
|
|
const label = t("system_status.labels.replication_percent", {
|
|
percent: Number.isFinite(percent) ? percent.toFixed(1) : "0.0",
|
|
status,
|
|
});
|
|
const coolify = coolifyAvailabilityForReplication(replication);
|
|
|
|
return coolify ? `${label} - Coolify ${coolify}` : label;
|
|
}
|
|
|
|
function coolifyAvailabilityForReplication(replication) {
|
|
const hosts = [
|
|
...(Array.isArray(replication.replicas) ? replication.replicas : []),
|
|
...(Array.isArray(replication.hosts) ? replication.hosts : []),
|
|
replication.primary,
|
|
].filter(Boolean);
|
|
const host = hosts.find((entry) => entry?.coolify?.availability_state || entry?.coolify?.deployment_status);
|
|
if (!host) {
|
|
return "";
|
|
}
|
|
|
|
const availability = String(host.coolify?.availability_state || host.coolify?.deployment_status || "").trim();
|
|
if (availability === "") {
|
|
return "";
|
|
}
|
|
return availability.replace(/_/g, " ");
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
|
return "--";
|
|
}
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let size = Number(value);
|
|
let unitIndex = 0;
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
function formatMinioBuckets(buckets) {
|
|
if (!Array.isArray(buckets) || buckets.length === 0) {
|
|
return "--";
|
|
}
|
|
const available = buckets.filter((bucket) => bucket.status === "ok").length;
|
|
return t("system_status.labels.buckets_available", { available, total: buckets.length });
|
|
}
|
|
|
|
function gatewayDisplayLabel(gateway) {
|
|
const label = String(gateway?.label || "").trim();
|
|
if (label !== "") {
|
|
return label;
|
|
}
|
|
|
|
const hostname = String(gateway?.hostname || "").trim();
|
|
if (hostname !== "") {
|
|
return hostname;
|
|
}
|
|
|
|
return `Gateway #${gateway?.id ?? "?"}`;
|
|
}
|
|
|
|
function gatewayDepartmentName(gateway) {
|
|
const departmentId = Number(gateway?.department_id ?? 0);
|
|
if (departmentId > 0 && gatewayDepartments.value[departmentId]) {
|
|
return gatewayDepartments.value[departmentId];
|
|
}
|
|
|
|
return t("system_status.gateways.department_fallback", {
|
|
id: departmentId > 0 ? departmentId : "?",
|
|
});
|
|
}
|
|
|
|
function gatewayOperationLabel(operation) {
|
|
const summaryLabel = String(operation?.summary?.label || "").trim();
|
|
if (summaryLabel !== "") {
|
|
return summaryLabel;
|
|
}
|
|
|
|
const operationType = String(operation?.type || "").trim();
|
|
if (operationType === "") {
|
|
return null;
|
|
}
|
|
|
|
return operationType
|
|
.toLowerCase()
|
|
.split("_")
|
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function gatewayPrimaryMessage(gateway) {
|
|
const errorMessage = String(gateway?.error_state?.message || "").trim();
|
|
if (errorMessage !== "") {
|
|
return errorMessage;
|
|
}
|
|
|
|
const diagnosticMessage = String(gateway?.diagnostics?.[0]?.message || "").trim();
|
|
return diagnosticMessage !== "" ? diagnosticMessage : null;
|
|
}
|
|
|
|
function normalizeGatewayFleetUsage(rows, fleetUsage) {
|
|
const gatewayCounts = fleetUsage && typeof fleetUsage === "object" ? fleetUsage.gateways || {} : {};
|
|
|
|
return {
|
|
total: toFiniteNumber(gatewayCounts.total, rows.length),
|
|
online: toFiniteNumber(
|
|
gatewayCounts.online,
|
|
rows.filter((gateway) => normalizeGatewayStatus(gateway?.status) === "ONLINE").length
|
|
),
|
|
degraded: toFiniteNumber(
|
|
gatewayCounts.degraded,
|
|
rows.filter((gateway) => normalizeGatewayStatus(gateway?.status) === "DEGRADED").length
|
|
),
|
|
offline: toFiniteNumber(
|
|
gatewayCounts.offline,
|
|
rows.filter((gateway) => normalizeGatewayStatus(gateway?.status) === "OFFLINE").length
|
|
),
|
|
};
|
|
}
|
|
|
|
function toFiniteNumber(value, fallback = 0) {
|
|
return Number.isFinite(Number(value)) ? Number(value) : Number(fallback || 0);
|
|
}
|
|
|
|
function gatewaySortRank(status) {
|
|
return gatewayStatusPriority[normalizeGatewayStatus(status)] ?? Number.MAX_SAFE_INTEGER;
|
|
}
|
|
|
|
function compareGateways(left, right) {
|
|
const rankDifference = gatewaySortRank(left?.status) - gatewaySortRank(right?.status);
|
|
if (rankDifference !== 0) {
|
|
return rankDifference;
|
|
}
|
|
|
|
const heartbeatDifference = gatewayHeartbeatTimestamp(left?.last_heartbeat_at) - gatewayHeartbeatTimestamp(right?.last_heartbeat_at);
|
|
if (heartbeatDifference !== 0) {
|
|
return heartbeatDifference;
|
|
}
|
|
|
|
return Number(left?.id ?? 0) - Number(right?.id ?? 0);
|
|
}
|
|
|
|
function gatewayHeartbeatTimestamp(value) {
|
|
if (!value) {
|
|
return 0;
|
|
}
|
|
|
|
const timestamp = new Date(value).getTime();
|
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
}
|
|
|
|
function modulePath(key) {
|
|
return moduleConfigPaths[key] || null;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="system-status-dashboard" data-testid="system-status-dashboard">
|
|
<div class="system-status-summary">
|
|
<article class="summary-card summary-card--overall" :class="statusClass(overallStatus)">
|
|
<div>
|
|
<p class="summary-label">{{ $t("system_status.summary.overall") }}</p>
|
|
<h2>{{ statusLabel(overallStatus) }}</h2>
|
|
</div>
|
|
<button class="button is-dark is-small" type="button" @click="refreshNow">
|
|
{{ $t("system_status.actions.refresh") }}
|
|
</button>
|
|
</article>
|
|
|
|
<article class="summary-card">
|
|
<p class="summary-label">{{ $t("system_status.summary.last_updated") }}</p>
|
|
<strong>{{ formatDate(snapshot?.generated_at) }}</strong>
|
|
</article>
|
|
|
|
<article class="summary-card">
|
|
<p class="summary-label">{{ $t("system_status.summary.active_users") }}</p>
|
|
<strong>{{ sessions.active_users }}</strong>
|
|
</article>
|
|
|
|
<article class="summary-card">
|
|
<p class="summary-label">{{ $t("system_status.summary.active_sessions") }}</p>
|
|
<strong>{{ sessions.active_sessions }}</strong>
|
|
</article>
|
|
</div>
|
|
|
|
<div v-if="loading && !snapshot" class="notification is-light">
|
|
{{ $t("system_status.states.loading") }}
|
|
</div>
|
|
|
|
<div v-if="error" class="notification is-danger is-light">
|
|
<strong>{{ $t("system_status.states.error") }}</strong>
|
|
<span>{{ error?.message || $t("system_status.states.error_generic") }}</span>
|
|
</div>
|
|
|
|
<div v-if="isStale" class="notification is-warning is-light">
|
|
{{ $t("system_status.states.stale") }}
|
|
</div>
|
|
|
|
<div v-if="warningEntries.length" class="notification is-warning is-light">
|
|
<strong>{{ $t("system_status.labels.warnings") }}</strong>
|
|
<ul class="warning-list">
|
|
<li
|
|
v-for="warning in warningEntries"
|
|
:key="`${warning.key || 'warning'}-${warning.message}`"
|
|
>
|
|
{{ warningText(warning) }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<b-tabs
|
|
v-model="activeDashboardTab"
|
|
expanded
|
|
type="is-boxed"
|
|
class="system-status-tabs"
|
|
data-testid="system-status-dashboard-tabs"
|
|
>
|
|
<b-tab-item value="infrastructure">
|
|
<template #header>
|
|
<b-icon pack="fas" icon="server" />
|
|
<span data-testid="system-status-tab-infrastructure">
|
|
{{ $t("system_status.sections.infrastructure") }}
|
|
</span>
|
|
</template>
|
|
|
|
<section class="system-status-section" data-testid="system-status-panel-infrastructure">
|
|
<div class="section-heading">
|
|
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
|
|
<RouterLink class="section-link" to="/superuser/system/replication">
|
|
{{ $t("system_status.actions.open_replication") }}
|
|
</RouterLink>
|
|
</div>
|
|
<div class="status-card-grid">
|
|
<article
|
|
v-for="card in statusCards"
|
|
:key="card.id"
|
|
class="status-card"
|
|
:class="statusClass(card.status)"
|
|
:data-testid="`status-card-${card.id}`"
|
|
>
|
|
<div class="status-card__top">
|
|
<p class="status-card__title">{{ card.title }}</p>
|
|
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
|
|
</div>
|
|
<strong class="status-card__primary">{{ card.primary }}</strong>
|
|
<p class="status-card__secondary">{{ card.secondary }}</p>
|
|
<small class="status-card__detail">{{ card.detail }}</small>
|
|
<small
|
|
v-if="card.replicationLabel"
|
|
class="status-card__detail status-card__replication"
|
|
:data-testid="`status-card-${card.id}-replication`"
|
|
>
|
|
{{ card.replicationLabel }}
|
|
</small>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
</b-tab-item>
|
|
|
|
<b-tab-item v-if="showGatewaySection" value="gateways">
|
|
<template #header>
|
|
<b-icon pack="fas" icon="network-wired" />
|
|
<span data-testid="system-status-tab-gateways">
|
|
{{ $t("system_status.sections.gateways") }}
|
|
</span>
|
|
</template>
|
|
|
|
<section
|
|
class="system-status-section"
|
|
data-testid="system-status-gateways"
|
|
>
|
|
<div class="section-heading">
|
|
<div>
|
|
<h3>{{ $t("system_status.sections.gateways") }}</h3>
|
|
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
|
|
</div>
|
|
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
|
|
{{ $t("system_status.gateways.actions.open_fleet") }}
|
|
</RouterLink>
|
|
</div>
|
|
|
|
<div class="gateway-summary-grid">
|
|
<article
|
|
v-for="card in gatewaySummaryCards"
|
|
:key="card.id"
|
|
class="summary-card gateway-summary-card"
|
|
:class="card.toneClass"
|
|
:data-testid="`gateway-summary-card-${card.id}`"
|
|
>
|
|
<p class="summary-label">{{ card.title }}</p>
|
|
<strong>{{ card.value }}</strong>
|
|
</article>
|
|
</div>
|
|
|
|
<div
|
|
v-if="gatewayError"
|
|
class="notification is-warning is-light gateway-notification"
|
|
data-testid="gateway-section-error"
|
|
>
|
|
{{ $t("system_status.gateways.error") }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="gatewayLoading && !gatewayCards.length"
|
|
class="notification is-light gateway-notification"
|
|
>
|
|
{{ $t("system_status.gateways.loading") }}
|
|
</div>
|
|
|
|
<div v-if="gatewayCards.length" class="gateway-grid">
|
|
<article
|
|
v-for="gateway in gatewayCards"
|
|
:key="gateway.id"
|
|
class="gateway-card"
|
|
:class="gateway.toneClass"
|
|
:data-testid="`gateway-card-${gateway.id}`"
|
|
>
|
|
<div class="gateway-card__top">
|
|
<div>
|
|
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
|
|
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
|
|
</div>
|
|
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
|
|
</div>
|
|
|
|
<div class="gateway-card__meta">
|
|
<span>
|
|
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
|
|
</span>
|
|
<span>
|
|
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
|
|
</span>
|
|
<span v-if="gateway.activeOperationLabel">
|
|
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
|
|
</span>
|
|
</div>
|
|
|
|
<p v-if="gateway.message" class="gateway-card__message">
|
|
{{ gateway.message }}
|
|
</p>
|
|
|
|
<RouterLink :to="gateway.link" class="module-card__link">
|
|
{{ $t("system_status.gateways.actions.open_gateway") }}
|
|
</RouterLink>
|
|
</article>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="!gatewayLoading && !gatewayError"
|
|
class="gateway-empty"
|
|
data-testid="gateway-empty-state"
|
|
>
|
|
{{ $t("system_status.gateways.empty") }}
|
|
</div>
|
|
</section>
|
|
|
|
</b-tab-item>
|
|
|
|
<b-tab-item value="modules">
|
|
<template #header>
|
|
<b-icon pack="fas" icon="puzzle-piece" />
|
|
<span data-testid="system-status-tab-modules">
|
|
{{ $t("system_status.sections.modules") }}
|
|
</span>
|
|
</template>
|
|
|
|
<section class="system-status-section" data-testid="system-status-panel-modules">
|
|
<div class="section-heading">
|
|
<h3>{{ $t("system_status.sections.modules") }}</h3>
|
|
</div>
|
|
<div class="module-grid">
|
|
<article
|
|
v-for="module in modules"
|
|
:key="module.key"
|
|
class="module-card"
|
|
:class="statusClass(module.status)"
|
|
:data-testid="`module-card-${module.key}`"
|
|
>
|
|
<div class="module-card__top">
|
|
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
|
|
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
|
|
</div>
|
|
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
|
|
{{ moduleReasonText(module) }}
|
|
</p>
|
|
<div class="module-card__meta">
|
|
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
|
|
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
|
|
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
|
|
</div>
|
|
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
|
|
{{ $t("system_status.actions.open_config") }}
|
|
</RouterLink>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
</b-tab-item>
|
|
|
|
<b-tab-item value="sessions">
|
|
<template #header>
|
|
<b-icon pack="fas" icon="users" />
|
|
<span data-testid="system-status-tab-sessions">
|
|
{{ $t("system_status.sections.sessions") }}
|
|
</span>
|
|
</template>
|
|
|
|
<section class="system-status-section" data-testid="system-status-panel-sessions">
|
|
<div class="section-heading">
|
|
<h3>{{ $t("system_status.sections.sessions") }}</h3>
|
|
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
|
|
</div>
|
|
|
|
<div class="table-container sessions-table">
|
|
<table class="table is-fullwidth is-hoverable">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ $t("system_status.table.active") }}</th>
|
|
<th>{{ $t("system_status.table.name") }}</th>
|
|
<th>{{ $t("system_status.table.type") }}</th>
|
|
<th>{{ $t("system_status.table.device") }}</th>
|
|
<th>{{ $t("system_status.table.route") }}</th>
|
|
<th>{{ $t("system_status.table.first_seen") }}</th>
|
|
<th>{{ $t("system_status.table.last_seen") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
|
|
<td>
|
|
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
|
|
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<strong>{{ sessionDisplayName(session) }}</strong>
|
|
<div class="session-context">{{ sessionContextLabel(session) }}</div>
|
|
</td>
|
|
<td>{{ formatSessionKind(session.session_kind) }}</td>
|
|
<td>{{ formatDeviceType(session.device_type) }}</td>
|
|
<td class="session-route">{{ session.last_route || "--" }}</td>
|
|
<td>{{ formatDate(session.first_seen_at) }}</td>
|
|
<td>{{ formatDate(session.last_seen_at) }}</td>
|
|
</tr>
|
|
<tr v-if="!sessions.recent_sessions?.length">
|
|
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
</b-tab-item>
|
|
</b-tabs>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.system-status-dashboard {
|
|
display: grid;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.system-status-summary {
|
|
display: grid;
|
|
gap: 1rem;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
}
|
|
|
|
.summary-card,
|
|
.status-card,
|
|
.module-card,
|
|
.gateway-card {
|
|
border: 1px solid #d7dde7;
|
|
border-radius: 8px;
|
|
padding: 1rem 1.1rem;
|
|
background: #ffffff;
|
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
|
|
}
|
|
|
|
.summary-card--overall {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.summary-label,
|
|
.status-card__title,
|
|
.module-card__title {
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
font-size: 0.72rem;
|
|
color: #64748b;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.system-status-section {
|
|
display: grid;
|
|
gap: 0.9rem;
|
|
}
|
|
|
|
.section-heading {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: end;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.section-heading h3 {
|
|
margin: 0;
|
|
font-size: 1.2rem;
|
|
color: #102a43;
|
|
}
|
|
|
|
.status-card-grid,
|
|
.module-grid,
|
|
.gateway-grid {
|
|
display: grid;
|
|
gap: 1rem;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
align-items: stretch;
|
|
}
|
|
|
|
.gateway-summary-grid {
|
|
display: grid;
|
|
gap: 1rem;
|
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
align-items: stretch;
|
|
}
|
|
|
|
.section-subtitle {
|
|
margin: 0.2rem 0 0;
|
|
color: #64748b;
|
|
}
|
|
|
|
.section-link {
|
|
font-weight: 600;
|
|
color: #0f4c81;
|
|
}
|
|
|
|
.system-status-tabs {
|
|
min-width: 0;
|
|
}
|
|
|
|
.system-status-tabs :deep(.tab-content) {
|
|
padding: 1rem 0 0;
|
|
}
|
|
|
|
.system-status-tabs :deep(.tabs ul) {
|
|
align-items: stretch;
|
|
}
|
|
|
|
.system-status-tabs :deep(.tabs a) {
|
|
min-height: 2.75rem;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.status-card__top,
|
|
.module-card__top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
align-items: start;
|
|
}
|
|
|
|
.status-card__primary {
|
|
display: block;
|
|
font-size: 1.2rem;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.status-card__secondary,
|
|
.status-card__detail,
|
|
.module-card small,
|
|
.module-card__meta,
|
|
.session-context {
|
|
color: #475569;
|
|
}
|
|
|
|
.status-card__detail {
|
|
display: block;
|
|
}
|
|
|
|
.module-card {
|
|
display: grid;
|
|
gap: 0.85rem;
|
|
height: 100%;
|
|
align-content: start;
|
|
}
|
|
|
|
.gateway-card {
|
|
display: grid;
|
|
gap: 0.85rem;
|
|
height: 100%;
|
|
align-content: start;
|
|
}
|
|
|
|
.module-card__top {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
align-items: start;
|
|
justify-content: stretch;
|
|
column-gap: 0.75rem;
|
|
row-gap: 0.35rem;
|
|
}
|
|
|
|
.gateway-card__top {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
align-items: start;
|
|
column-gap: 0.75rem;
|
|
row-gap: 0.35rem;
|
|
}
|
|
|
|
.module-card__title {
|
|
margin: 0;
|
|
flex: 1 1 12rem;
|
|
min-width: 0;
|
|
text-transform: none;
|
|
letter-spacing: 0.04em;
|
|
line-height: 1.35;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.module-card__top .status-pill {
|
|
justify-self: end;
|
|
margin-left: 0;
|
|
}
|
|
|
|
.gateway-card__title {
|
|
display: block;
|
|
color: #0f172a;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.gateway-card__subtitle {
|
|
margin: 0.2rem 0 0;
|
|
color: #475569;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.module-card__reason {
|
|
margin: 0;
|
|
color: #475569;
|
|
line-height: 1.65;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.module-card__meta {
|
|
display: grid;
|
|
gap: 0.25rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.gateway-card__meta {
|
|
display: grid;
|
|
gap: 0.25rem;
|
|
font-size: 0.85rem;
|
|
color: #475569;
|
|
}
|
|
|
|
.gateway-card__message {
|
|
margin: 0;
|
|
color: #7c2d12;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.module-card__link {
|
|
margin-top: auto;
|
|
font-weight: 600;
|
|
color: #0f4c81;
|
|
}
|
|
|
|
.gateway-empty {
|
|
border: 1px dashed #cbd5e1;
|
|
border-radius: 8px;
|
|
padding: 1rem 1.1rem;
|
|
color: #475569;
|
|
background: rgba(248, 250, 252, 0.8);
|
|
}
|
|
|
|
.gateway-notification {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.status-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 88px;
|
|
padding: 0.35rem 0.7rem;
|
|
border-radius: 999px;
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.is-ok {
|
|
background: rgba(22, 163, 74, 0.12);
|
|
color: #166534;
|
|
}
|
|
|
|
.is-degraded {
|
|
background: rgba(245, 158, 11, 0.14);
|
|
color: #b45309;
|
|
}
|
|
|
|
.is-down {
|
|
background: rgba(220, 38, 38, 0.12);
|
|
color: #b91c1c;
|
|
}
|
|
|
|
.warning-list {
|
|
margin: 0.5rem 0 0;
|
|
padding-left: 1.1rem;
|
|
}
|
|
|
|
.sessions-table th {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.session-route {
|
|
max-width: 280px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.summary-card--overall,
|
|
.section-heading {
|
|
align-items: start;
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|