Files
pleno-vue/src/components/displays/superuser/system/SystemStatusDashboard.vue
T

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>