<!-- Карточка источника: описание, ключи (выпуск/отзыв, токен показывается один
раз — в БД только 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>