<script setup>
// Обзор: состояние сервиса и простое «куда идти дальше» для первого захода.
// Профиль и выход живут в каркасе (App.vue, #footer), heartbeat сессии — тоже
// там; здесь healthz + admin-сводка (/api/v1/admin/stats: справочники, поток,
// БД, воркер, SSO). Роль user справку по healthz и не видит админ-чисел.
import { computed, onMounted, ref } from "vue";
import { GnBadge, GnLoader, GnMetricCard, GnPageHeader, GnStatusCard } from "gnexus-ui-kit/vue";
import { adminStats, getSession, healthz } from "../api.js";
import { badgeFor } from "../ui.js";
import { t } from "../i18n/index.js";
const health = ref(null);
const stats = ref(null); // null = не админ или не загрузилась — блок просто не рисуем
const loading = ref(true);
const error = ref("");
// Сессию берём из кеша роут-гейта (router.js уже сходил за /me до здесь).
let cachedSession = null;
const isAdmin = computed(() =>
["admin", "superadmin"].includes(cachedSession?.system_role || ""),
);
// Фиксированный порядок статусов (ui.js badgeFor); из ответа показываем только
// ненулевые — нулевые строчки на пустом хабе шумят.
const EVENT_STATUSES = ["queued", "processing", "done", "failed", "expired"];
const DELIVERY_STATUSES = ["pending", "delivered", "failed", "skipped"];
const eventRows = computed(() =>
EVENT_STATUSES.filter((s) => stats.value?.events?.[s]).map((s) => ({
...badgeFor(s),
count: stats.value.events[s],
})),
);
const deliveryRows = computed(() =>
DELIVERY_STATUSES.filter((s) => stats.value?.deliveries?.[s]).map((s) => ({
...badgeFor(s),
count: stats.value.deliveries[s],
})),
);
const systemOk = computed(
() => stats.value && stats.value.database === "ok" && stats.value.worker !== "unavailable",
);
const systemProblems = computed(() => {
if (!stats.value) return [];
const problems = [];
if (stats.value.database !== "ok") problems.push(t("dashboard.dbDown"));
if (stats.value.worker === "unavailable") problems.push(t("dashboard.workerDown"));
return problems;
});
onMounted(async () => {
try {
cachedSession = await getSession();
const jobs = [healthz().then((h) => (health.value = h))];
if (isAdmin.value) jobs.push(adminStats().then((s) => (stats.value = s)));
await Promise.allSettled(jobs);
} catch (ex) {
error.value = t("dashboard.errorUnavailable", { message: ex.message });
} finally {
loading.value = false;
}
});
</script>
<template>
<main class="container page">
<GnPageHeader kicker="Synapse" :title="t('dashboard.title')" :subtitle="t('dashboard.subtitle')" />
<GnLoader v-if="loading" circle :label="t('dashboard.loaderChecking')" />
<GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
<template v-else>
<!-- Система: зелёная карточка, когда всё под нагрузкой отвечает; иначе — что болит -->
<GnStatusCard
v-if="stats && systemOk"
variant="info"
icon="ph-check-circle"
:title="t('dashboard.systemOkTitle')"
:text="`${t('dashboard.systemOkText')} · ${stats.sso_configured ? t('dashboard.ssoOk') : t('dashboard.ssoMissing')}`"
/>
<GnStatusCard
v-else-if="stats && systemProblems.length"
variant="warning"
icon="ph-warning-circle"
:title="t('dashboard.systemWarnTitle')"
:text="systemProblems.join(' · ')"
/>
<!-- Справочники маршрутизации (admin) -->
<div v-if="stats" class="metrics">
<GnMetricCard
:label="t('nav.sources')"
:value="stats.sources"
icon="ph-plugs-connected"
:meta="t('dashboard.metricSourcesMeta')"
/>
<GnMetricCard :label="t('nav.types')" :value="stats.types" icon="ph-shapes" />
<GnMetricCard :label="t('nav.targets')" :value="stats.targets" icon="ph-target" />
<GnMetricCard :label="t('nav.rules')" :value="stats.rules" icon="ph-funnel" />
</div>
<!-- Поток: статусы события/доставки с количеством; пусто — так и говорим -->
<div v-if="stats" class="flow">
<div class="flow-col">
<div class="section-title">{{ t("dashboard.flowEvents") }}</div>
<div v-if="eventRows.length" class="flow-rows">
<div v-for="row in eventRows" :key="row.label" class="flow-row">
<GnBadge :variant="row.variant" outline>{{ row.label }}</GnBadge>
<span class="flow-count">{{ row.count }}</span>
</div>
</div>
<p v-else class="form-note">{{ t("dashboard.flowEmpty") }}</p>
</div>
<div class="flow-col">
<div class="section-title">{{ t("dashboard.flowDeliveries") }}</div>
<div v-if="deliveryRows.length" class="flow-rows">
<div v-for="row in deliveryRows" :key="row.label" class="flow-row">
<GnBadge :variant="row.variant" outline>{{ row.label }}</GnBadge>
<span class="flow-count">{{ row.count }}</span>
</div>
</div>
<p v-else class="form-note">{{ t("dashboard.flowEmpty") }}</p>
</div>
</div>
<!-- Навигатор для первого захода: что настроить и в каком порядке (нужен,
только пока нет ни одного источника) -->
<GnStatusCard
v-if="!stats || stats.sources === 0"
variant="info"
icon="ph-info"
:title="t('dashboard.getStartTitle')"
:text="t('dashboard.getStartText')"
/>
</template>
</main>
</template>
<style scoped>
/* Максимум 1440 по требованию макета; ниже — обычный поток. */
.page {
max-width: 1440px;
margin-inline: auto;
padding-block: var(--gn-space-6);
display: flex;
flex-direction: column;
gap: var(--gn-space-4);
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--gn-space-4);
}
.flow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--gn-space-4);
}
.flow-col {
display: flex;
flex-direction: column;
gap: var(--gn-space-3);
}
.flow-rows {
display: flex;
flex-direction: column;
gap: var(--gn-space-2);
}
.flow-row {
display: flex;
align-items: center;
gap: var(--gn-space-3);
}
.flow-count {
font-variant-numeric: tabular-nums;
}
.section-title {
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.8em;
font-weight: 600;
}
.form-note {
font-size: 0.85em;
opacity: 0.7;
margin: 0;
}
</style>