Newer
Older
gn-synapse / frontend / src / views / DeliveriesView.vue
<script setup>
// Лог доставок: последние N с фильтрами по каналу и статусу.
import { onMounted, ref } from "vue";
import {
  GnBadge,
  GnIconButton,
  GnLoader,
  GnPageHeader,
  GnSelect,
  GnStatusCard,
  GnTable,
} from "gnexus-ui-kit/vue";
import { useToast } from "gnexus-ui-kit/vue";
import { api } from "../api.js";
import { badgeFor, fmtTime, th, truncate } from "../ui.js";

const toast = useToast();

const rows = ref([]);
const loading = ref(true);
const error = ref("");

const CHANNELS = [
  { value: "", label: "все" },
  { value: "telegram", label: "telegram" },
  { value: "email", label: "email" },
  { value: "s2s", label: "s2s" },
  { value: "internal_log", label: "internal_log" },
];
const STATUSES = [
  { value: "", label: "все" },
  { value: "pending", label: "pending" },
  { value: "delivered", label: "delivered" },
  { value: "failed", label: "failed" },
  { value: "skipped", label: "skipped" },
];

const channel = ref("");
const status = ref("");

async function load() {
  loading.value = true;
  error.value = "";
  try {
    const query = { limit: 100 };
    if (channel.value) query.channel = channel.value;
    if (status.value) query.status = status.value;
    rows.value = await api.listDeliveries(query);
  } catch (ex) {
    error.value = ex.message;
    toast.error(ex.message);
  } finally {
    loading.value = false;
  }
}

onMounted(load);
</script>

<template>
  <main class="container page">
    <GnPageHeader
      kicker="Synapse"
      title="Доставки"
      subtitle="Лог попыток: канал, цель, статус, ретраи и текст, реально ушедший в канал"
    />

    <!-- Фильтры отдельным блоком, с подписями -->
    <div class="filters">
      <GnSelect v-model="status" label="Статус" :options="STATUSES" @update:model-value="load" />
      <GnSelect v-model="channel" label="Канал" :options="CHANNELS" @update:model-value="load" />
      <div class="refresh-slot">
        <GnIconButton icon="ph-arrow-counter-clockwise" label="Обновить" @click="load" />
      </div>
    </div>

    <GnLoader v-if="loading" circle label="Загрузка…" />
    <GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
    <template v-else>
      <GnTable
        :rows="rows"
        empty-text="Доставок ещё не было"
        :columns="[
          { key: 'created_at', label: 'Время' },
          { key: 'event', label: th('Событие', 'Идентификатор события, породившего доставку') },
          { key: 'channel', label: 'Канал' },
          { key: 'target_name', label: 'Цель' },
          { key: 'status', label: th('Статус', 'delivered — доставлено; pending — ждёт отправки или ретрая; skipped — отсеял шумодав; failed — попытки кончились') },
          { key: 'attempts', label: th('Попытки', 'Сколько раз пытались отправить (ретраи 30с → 2мин → 10мин → 30мин)') },
          { key: 'last_error', label: 'Ошибка' },
          { key: 'rendered_message', label: 'Сообщение' },
        ]"
      >
        <template #cell-created_at="{ value }">{{ fmtTime(value) }}</template>
        <template #cell-event="{ row }">
          <code class="event-id">{{ truncate(row.event_id, 13) }}</code>
        </template>
        <template #cell-status="{ value }">
          <GnBadge :variant="badgeFor(value).variant" outline>{{ value }}</GnBadge>
        </template>
        <template #cell-attempts="{ value }">{{ value ?? 0 }}</template>
        <template #cell-last_error="{ value }">{{ truncate(value, 60) || "—" }}</template>
        <template #cell-rendered_message="{ value }">
          <span :title="value || ''">{{ truncate(value, 60) || "—" }}</span>
        </template>
      </GnTable>
    </template>
  </main>
</template>

<style scoped>
.page {
  max-width: 1440px;
  margin-inline: auto;
  padding-block: var(--gn-space-6);
  display: flex;
  flex-direction: column;
  gap: var(--gn-space-4);
}
.filters {
  display: flex;
  align-items: flex-end;
  gap: var(--gn-space-4);
}
/* Кнопка обновления центрируется точно по селекту: flex-end у .filters
   выравнивает по краям отступов (у form-group кита margin-bottom 15px),
   поэтому slot поднят тем же отступом + 5px внутреннего запаса группы,
   высота = высоте инпута кита (46px), кнопка внутри по центру. */
.refresh-slot {
  height: 46px;
  display: flex;
  align-items: center;
  margin-bottom: var(--gn-space-5);
}
.event-id {
  font-size: 0.78em;
}
</style>