Newer
Older
gn-synapse / frontend / src / views / SourceDetailView.vue
<!-- Карточка источника: описание, ключи (выпуск/отзыв, токен показывается один
     раз — в БД только sha256), зарегистрированные типы событий источника. -->
<script setup>
import { computed, onMounted, ref } from "vue";
import {
  GnAlert,
  GnBadge,
  GnButton,
  GnConfirmDialog,
  GnCopyButton,
  GnDropdown,
  GnIconButton,
  GnLoader,
  GnModal,
  GnPageHeader,
  GnStatusCard,
  GnTable,
} from "gnexus-ui-kit/vue";
import { useToast } from "gnexus-ui-kit/vue";
import { useRoute, useRouter } from "vue-router";
import { api } from "../api.js";
import { fmtTime, th } from "../ui.js";

const toast = useToast();
const route = useRoute();
const router = useRouter();
const sourceId = Number(route.params.id);

const source = ref(null);
const keys = ref([]);
const types = ref([]);
const loading = ref(true);
const error = ref("");

async function load() {
  loading.value = true;
  error.value = "";
  try {
    const [src, allTypes] = await Promise.all([api.listSources(), api.listTypes()]);
    source.value = src.find((s) => s.id === sourceId) || null;
    if (!source.value) {
      error.value = "Источник не найден";
      return;
    }
    types.value = allTypes.filter((t) => t.source_id === sourceId || t.source_name === source.value.name);
    keys.value = await api.listKeys(sourceId);
  } catch (ex) {
    error.value = ex.message;
  } finally {
    loading.value = false;
  }
}

async function createKey() {
  try {
    const created = await api.createKey(sourceId, { name: source.value.name });
    tokenModal.value = created;
    keys.value = await api.listKeys(sourceId);
  } catch (ex) {
    toast.error(ex.message);
  }
}

// Токен показывается один раз
const tokenModal = ref(null);

async function revokeKey(key) {
  try {
    await api.revokeKey(sourceId, key.id);
    keys.value = await api.listKeys(sourceId);
    toast.success("Ключ отозван");
  } catch (ex) {
    toast.error(ex.message);
  }
}

const confirmDelete = ref(false);
async function doDelete() {
  try {
    await api.deleteSource(sourceId);
    toast.success("Источник удалён");
    router.push("/sources");
  } catch (ex) {
    // 409: у источника есть события — удалить нельзя
    toast.error(ex.message);
    confirmDelete.value = false;
  }
}

const createdAt = computed(() => fmtTime(source.value?.created_at));
onMounted(load);
</script>

<template>
  <main class="container page">
    <RouterLink to="/sources" class="back-link"><i class="ph ph-arrow-left"></i> К списку источников</RouterLink>
    <GnPageHeader kicker="Synapse · Источник" :title="source?.name || '…'" :subtitle="source?.label || ''">
      <template #actions>
        <GnDropdown
          label="Действия"
          :items="[
            { label: 'Выпустить ключ', icon: 'ph-key', onSelect: createKey },
            { label: 'Удалить источник', icon: 'ph-trash', danger: true, onSelect: () => (confirmDelete = true) },
          ]"
        />
      </template>
    </GnPageHeader>

    <GnLoader v-if="loading" />
    <GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
    <template v-else>
      <!-- Паспорт источника -->
      <section class="panel">
        <h3 class="panel-title">О сервисе</h3>
        <dl class="meta">
          <div class="meta-row">
            <dt>Slug</dt>
            <dd><code>{{ source.name }}</code></dd>
          </div>
          <div class="meta-row">
            <dt>Название</dt>
            <dd>{{ source.label || "—" }}</dd>
          </div>
          <div class="meta-row">
            <dt>Описание</dt>
            <dd>{{ source.description || "—" }}</dd>
          </div>
          <div class="meta-row">
            <dt>Создан</dt>
            <dd>{{ createdAt }}</dd>
          </div>
        </dl>
      </section>

      <!-- API-ключи -->
      <section class="panel">
        <div class="panel-head">
          <h3 class="panel-title">API-ключи</h3>
          <GnButton variant="primary" size="sm" icon="ph-plus" @click="createKey">Выпустить ключ</GnButton>
        </div>
        <p class="panel-note">
          Токен <code>syn_*</code> показывается один раз при выпуске — в Synapse хранится только sha256-хэш.
        </p>
        <GnTable
          :rows="keys"
          empty-text="Ключей нет — выпустите первый"
          :columns="[
            { key: 'name', label: 'Ключ' },
            { key: 'token_hint', label: 'Хвост' },
            { key: 'created_at', label: th('Создан', 'Момент выпуска ключа') },
            { key: 'last_used_at', label: th('Использован', 'Последнее событие с этим ключом') },
            { key: 'revoked_at', label: 'Статус' },
            { key: 'actions', label: '' },
          ]"
        >
          <template #cell-created_at="{ value }">{{ fmtTime(value) }}</template>
          <template #cell-last_used_at="{ value }">{{ fmtTime(value) }}</template>
          <template #cell-revoked_at="{ value }">
            <GnBadge :variant="value ? 'danger' : 'success'" outline>
              {{ value ? "отозван" : "активен" }}
            </GnBadge>
          </template>
          <template #cell-actions="{ row }">
            <GnIconButton
              v-if="!row.revoked_at"
              icon="ph-x-circle"
              size="sm"
              label="Отозвать"
              @click="revokeKey(row)"
            />
          </template>
        </GnTable>
      </section>

      <!-- Типы событий -->
      <section class="panel">
        <div class="panel-head">
          <h3 class="panel-title">Типы событий</h3>
          <GnButton variant="secondary" size="sm" icon="ph-shapes" @click="router.push('/types')">
            Все типы
          </GnButton>
        </div>
        <GnTable
          :rows="types"
          empty-text="Типов не зарегистрировано — событие с незнакомой тройкой будет отклонено с 422"
          :columns="[
            { key: 'subject', label: th('Subject', 'Сущность события: container, issue, build…') },
            { key: 'action', label: th('Action', 'Что случилось: down, created, resolved…') },
            { key: 'created_at', label: th('Создан', 'Момент регистрации типа') },
          ]"
        >
          <template #cell-created_at="{ value }">{{ fmtTime(value) }}</template>
        </GnTable>
      </section>
    </template>

    <!-- Токен показывается один раз -->
    <GnModal
      v-if="tokenModal"
      :open="tokenModal !== null" @update:open="(v) => (v ? null : (tokenModal = null))"
      title="Токен — показывается один раз"
      :close-on-backdrop="false"
    >
      <GnAlert variant="warning">
        <i class="ph ph-warning"></i>
        Токен больше не будет показан: в Synapse хранится только sha256-хэш.
        Скопируйте сейчас и отдайте владельцу источника.
      </GnAlert>
      <p class="token-wrap"><code>{{ tokenModal.token }}</code></p>
      <template #actions>
        <GnCopyButton class="token-copy" :text="tokenModal.token" label="Скопировать" icon="ph-copy" />
        <GnButton variant="primary" @click="tokenModal = null">Готово</GnButton>
      </template>
    </GnModal>

    <!-- Удаление источника -->
    <GnConfirmDialog
      v-model:open="confirmDelete"
      title="Удалить источник?"
      :message="`Источник «${source?.name}» будет удалён вместе с типами и ключами. Если у источника есть события, удаление будет отклонено.`"
      confirm-variant="danger"
      confirm-text="Удалить"
      cancel-text="Отмена"
      @confirm="doDelete"
    />
  </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);
}
.back-link {
  width: max-content;
  display: inline-flex;
  align-items: center;
  gap: var(--gn-space-1);
  color: inherit;
  text-decoration: none;
  font-size: 0.85em;
  opacity: 0.75;
}
.back-link:hover {
  opacity: 1;
}
.panel {
  background: var(--gn-surface-panel);
  border: 1px solid var(--gn-border-color-muted);
  border-radius: 6px;
  padding: var(--gn-space-4) var(--gn-space-5);
  display: flex;
  flex-direction: column;
  gap: var(--gn-space-3);
}
.panel-title {
  margin: 0;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-size: 0.8em;
}
.panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gn-space-3);
}
.panel-note {
  margin: 0;
  font-size: 0.82em;
  opacity: 0.75;
}
.meta {
  margin: 0;
  display: grid;
  gap: var(--gn-space-2);
}
.meta-row {
  display: grid;
  grid-template-columns: minmax(120px, 160px) 1fr;
  gap: var(--gn-space-3);
}
.meta-row dt {
  color: var(--gn-color-text-medium);
  font-size: 0.82em;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.meta-row dd {
  margin: 0;
}
.token-wrap {
  word-break: break-all;
  background: var(--gn-surface-panel-muted);
  padding: var(--gn-space-3);
  border-radius: 6px;
  overflow-wrap: anywhere;
}
.token-copy {
  margin-right: auto;
}
</style>