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 { t } from "../i18n/index.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 = t("sourceDetail.notFound");
      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(t("sourceDetail.toastKeyRevoked"));
  } catch (ex) {
    toast.error(ex.message);
  }
}

const confirmDelete = ref(false);
async function doDelete() {
  try {
    await api.deleteSource(sourceId);
    toast.success(t("sourceDetail.toastDeleted"));
    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> {{ t("sourceDetail.backToList") }}</RouterLink>
    <GnPageHeader :kicker="t('sourceDetail.kicker')" :title="source?.name || '…'" :subtitle="source?.label || ''">
      <template #actions>
        <GnDropdown
          :label="t('sourceDetail.actions')"
          :items="[
            { label: t('sourceDetail.issueKey'), icon: 'ph-key', onSelect: createKey },
            { label: t('sourceDetail.deleteSource'), icon: 'ph-trash', danger: true, onSelect: () => (confirmDelete = true) },
          ]"
        />
      </template>
    </GnPageHeader>

    <GnLoader v-if="loading" circle :label="t('sourceDetail.loading')" />
    <GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
    <template v-else>
      <!-- Паспорт источника -->
      <section class="panel">
        <h3 class="panel-title">{{ t("sourceDetail.aboutTitle") }}</h3>
        <dl class="meta">
          <div class="meta-row">
            <dt>{{ t("sourceDetail.dtSlug") }}</dt>
            <dd><code>{{ source.name }}</code></dd>
          </div>
          <div class="meta-row">
            <dt>{{ t("sourceDetail.dtName") }}</dt>
            <dd>{{ source.label || "—" }}</dd>
          </div>
          <div class="meta-row">
            <dt>{{ t("sourceDetail.dtDescription") }}</dt>
            <dd>{{ source.description || "—" }}</dd>
          </div>
          <div class="meta-row">
            <dt>{{ t("sourceDetail.dtCreated") }}</dt>
            <dd>{{ createdAt }}</dd>
          </div>
        </dl>
      </section>

      <!-- API-ключи -->
      <section class="panel">
        <div class="panel-head">
          <h3 class="panel-title">{{ t("sourceDetail.keysTitle") }}</h3>
          <GnButton variant="primary" size="sm" icon="ph-plus" @click="createKey">{{ t("sourceDetail.issueKey") }}</GnButton>
        </div>
        <p class="panel-note">{{ t("sourceDetail.tokenLabel") }} <code>syn_*</code>{{ t("sourceDetail.tokenNote") }}</p>
        <GnTable
          :rows="keys"
          :empty-text="t('sourceDetail.keysEmpty')"
          :columns="[
            { key: 'name', label: t('sourceDetail.colKey') },
            { key: 'token_hint', label: t('sourceDetail.colTail') },
            { key: 'created_at', label: th(t('sourceDetail.dtCreated'), t('sourceDetail.colCreatedHelp')) },
            { key: 'last_used_at', label: th(t('sourceDetail.colUsed'), t('sourceDetail.colUsedHelp')) },
            { key: 'revoked_at', label: t('sourceDetail.colStatus') },
            { 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 ? t("sourceDetail.keyRevoked") : t("sourceDetail.keyActive") }}
            </GnBadge>
          </template>
          <template #cell-actions="{ row }">
            <GnIconButton
              v-if="!row.revoked_at"
              icon="ph-x-circle"
              size="sm"
              :label="t('sourceDetail.revoke')"
              @click="revokeKey(row)"
            />
          </template>
        </GnTable>
      </section>

      <!-- Типы событий -->
      <section class="panel">
        <div class="panel-head">
          <h3 class="panel-title">{{ t("sourceDetail.typesTitle") }}</h3>
          <GnButton variant="secondary" size="sm" icon="ph-shapes" @click="router.push('/types')">
            {{ t("sourceDetail.allTypes") }}
          </GnButton>
        </div>
        <GnTable
          :rows="types"
          :empty-text="t('sourceDetail.typesEmpty')"
          :columns="[
            { key: 'subject', label: th(t('sourceDetail.colSubject'), t('sourceDetail.colSubjectHelp')) },
            { key: 'action', label: th(t('sourceDetail.colAction'), t('sourceDetail.colActionHelp')) },
            { key: 'created_at', label: th(t('sourceDetail.dtCreated'), t('sourceDetail.colTypeCreatedHelp')) },
          ]"
        >
          <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="t('sourceDetail.tokenModalTitle')"
      :close-on-backdrop="false"
    >
      <GnAlert variant="warning">
        <i class="ph ph-warning"></i>
        {{ t("sourceDetail.tokenModalText") }}
      </GnAlert>
      <p class="token-wrap"><code>{{ tokenModal.token }}</code></p>
      <template #actions>
        <GnCopyButton class="token-copy" :text="tokenModal.token" :label="t('sourceDetail.copy')" icon="ph-copy" />
        <GnButton variant="primary" @click="tokenModal = null">{{ t("sourceDetail.done") }}</GnButton>
      </template>
    </GnModal>

    <!-- Удаление источника -->
    <GnConfirmDialog
      v-model:open="confirmDelete"
      :title="t('sourceDetail.confirmTitle')"
      :message="t('sourceDetail.confirmMessage', { name: source?.name })"
      confirm-variant="danger"
      :confirm-text="t('sourceDetail.delete')"
      :cancel-text="t('sourceDetail.cancel')"
      @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>