Newer
Older
gn-synapse / frontend / src / views / DashboardView.vue
<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>