Newer
Older
gn-synapse / frontend / src / views / SourcesView.vue
<script setup>
// Источники событий и их API-ключи. Токен нового ключа показывается ровно
// один раз (модаль с GnCopyButton) — в БД лежит только sha256.
import { onMounted, ref } from "vue";
import {
  GnBadge,
  GnButton,
  GnConfirmDialog,
  GnCopyButton,
  GnInput,
  GnLoader,
  GnModal,
  GnPageHeader,
  GnStatusCard,
  GnTable,
  GnTextarea,
} from "gnexus-ui-kit/vue";
import { useToast } from "gnexus-ui-kit/vue";
import { api } from "../api.js";
import { fmtTime } from "../ui.js";

const toast = useToast();

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

async function load() {
  loading.value = true;
  error.value = "";
  try {
    sources.value = await api.listSources();
  } catch (ex) {
    error.value = ex.message;
  } finally {
    loading.value = false;
  }
}

// --- создание источника ---
const showCreate = ref(false);
const form = ref({ name: "", label: "", description: "" });
const busy = ref(false);

async function createSource() {
  if (!form.value.name) return;
  busy.value = true;
  try {
    await api.createSource({ ...form.value, name: form.value.name.trim() });
    showCreate.value = false;
    form.value = { name: "", label: "", description: "" };
    toast.success("Источник создан");
    await load();
  } catch (ex) {
    toast.error(ex.message);
  } finally {
    busy.value = false;
  }
}

// --- удаление источника ---
const confirmDelete = ref(null); // источник
async function doDelete() {
  try {
    await api.deleteSource(confirmDelete.value.id);
    toast.success("Источник удалён");
    await load();
  } catch (ex) {
    toast.error(ex.message);
  } finally {
    confirmDelete.value = null;
  }
}

// --- ключи ---
const keysSource = ref(null); // источник, чьи ключи в модали
const keys = ref([]);
const keysLoading = ref(false);

async function openKeys(source) {
  keysSource.value = source;
  keysLoading.value = true;
  try {
    keys.value = await api.listKeys(source.id);
  } catch (ex) {
    toast.error(ex.message);
  } finally {
    keysLoading.value = false;
  }
}

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

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

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

onMounted(load);
</script>

<template>
  <main class="container page">
    <GnPageHeader
      kicker="Synapse"
      title="Источники"
      subtitle="Сервисы, которые шлют события в хаб. К каждому — API-ключи syn_*"
    >
      <template #actions>
        <GnButton variant="primary" icon="ph-plus" @click="showCreate = true">Источник</GnButton>
      </template>
    </GnPageHeader>

    <GnLoader v-if="loading" />
    <GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
    <template v-else>
      <GnTable
        :rows="sources"
        empty-text="Источников ещё нет — создайте первый"
        :columns="[
          { key: 'name', label: 'Slug' },
          { key: 'label', label: 'Название' },
          { key: 'description', label: 'Описание' },
          { key: 'keys_count', label: 'Ключи' },
          { key: 'types_count', label: 'Типы' },
          { key: 'created_at', label: 'Создан' },
          { key: 'actions', label: '' },
        ]"
      >
        <template #cell-keys_count="{ value }">{{ value ?? 0 }}</template>
        <template #cell-created_at="{ value }">{{ fmtTime(value) }}</template>
        <template #cell-actions="{ row }">
          <span class="row-actions">
            <GnButton variant="secondary" size="sm" icon="ph-key" @click="openKeys(row)">Ключи</GnButton>
            <GnButton variant="danger" size="sm" icon="ph-trash" @click="confirmDelete = row" />
          </span>
        </template>
      </GnTable>
    </template>

    <!-- Создание источника -->
    <GnModal v-model:open="showCreate" title="Новый источник">
      <div class="form">
        <GnInput v-model="form.name" label="Slug (name)" icon="ph-hash" help="Например monitoring; только a-z 0-9 . _ -" />
        <GnInput v-model="form.label" label="Название" icon="ph-tag" help="Человекочитаемое имя" />
        <GnTextarea
          v-model="form.description"
          label="Описание"
          help="Что это за сервис — это увидят человек и ИИ-агент в админке"
        />
      </div>
      <template #actions>
        <GnButton variant="secondary" @click="showCreate = false">Отмена</GnButton>
        <GnButton variant="primary" :loading="busy" :disabled="!form.name.trim()" @click="createSource">
          Создать
        </GnButton>
      </template>
    </GnModal>

    <!-- Ключи источника -->
    <GnModal :open="keysSource !== null" @update:open="(v) => (v ? null : (keysSource = null))" :title="keysSource ? `Ключи — ${keysSource.name}` : 'Ключи'">
      <GnLoader v-if="keysLoading" />
      <template v-else>
        <GnButton variant="primary" icon="ph-plus" @click="createKey">Выпустить ключ</GnButton>
        <GnTable
          class="keys-table"
          :rows="keys"
          empty-text="Ключей нет — выпустите"
          :columns="[
            { key: 'name', label: 'Ключ' },
            { key: 'token_hint', label: 'Хвост' },
            { key: 'created_at', label: 'Создан' },
            { key: 'last_used_at', label: 'Использован' },
            { 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 }">
            <GnButton
              v-if="!row.revoked_at"
              variant="danger"
              size="sm"
              icon="ph-x-circle"
              @click="revokeKey(row)"
            >
              Отозвать
            </GnButton>
          </template>
        </GnTable>
      </template>
      <template #actions="{ close }">
        <GnButton variant="secondary" @click="close">Закрыть</GnButton>
      </template>
    </GnModal>

    <!-- Токен показывается один раз -->
    <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.key.token }}</code></p>
      <template #actions>
        <GnCopyButton class="token-copy" :text="tokenModal.key.token" label="Скопировать" icon="ph-copy" />
        <GnButton variant="primary" @click="tokenModal = null">Готово</GnButton>
      </template>
    </GnModal>

    <!-- Удаление источника -->
    <GnConfirmDialog
      :open="confirmDelete !== null" @update:open="(v) => (v ? null : (confirmDelete = null))"
      :key="confirmDelete?.id"
      title="Удалить источник?"
      :message="confirmDelete ? `Источник «${confirmDelete.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);
}
.row-actions {
  display: flex;
  gap: var(--gn-space-2);
}
.form {
  display: flex;
  flex-direction: column;
  gap: var(--gn-space-4);
}
.keys-table {
  margin-top: var(--gn-space-4);
}
.token-wrap {
  word-break: break-all;
  background: var(--gn-surface-elevated);
  padding: var(--gn-space-3);
  border-radius: var(--gn-radius-sm);
  overflow-wrap: anywhere;
}
.token-copy {
  margin-right: auto;
}
</style>