diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d2b6de1..4daef8d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -20,10 +20,11 @@ const router = useRouter(); // Секции админки; иконки — только Phosphor (правила кита). +// pulse/shapes в шрифте Phosphor из кита отсутствуют — берём activity/squares-four. const items = [ - { to: "/dashboard", label: "Обзор", icon: "ph-pulse" }, + { to: "/dashboard", label: "Обзор", icon: "ph-activity" }, { to: "/sources", label: "Источники", icon: "ph-broadcast" }, - { to: "/types", label: "Типы", icon: "ph-shapes" }, + { to: "/types", label: "Типы", icon: "ph-squares-four" }, { to: "/targets", label: "Цели", icon: "ph-target" }, { to: "/rules", label: "Правила", icon: "ph-funnel" }, { to: "/deliveries", label: "Доставки", icon: "ph-paper-plane-tilt" }, @@ -36,7 +37,14 @@ "/rules": "Правила", "/deliveries": "Доставки", }; -const current = computed(() => currents[route.path] || ""); +// Префиксно: /sources/7 тоже подписывается «Источники». +const current = computed( + () => + Object.entries(currents) + .filter(([p]) => route.path === p || route.path.startsWith(p + "/")) + .sort((a, b) => b[0].length - a[0].length) + .map(([, v]) => v)[0] || "", +); const bare = computed(() => Boolean(route.meta.bare)); const me = ref(null); diff --git a/frontend/src/components/CodeBlock.vue b/frontend/src/components/CodeBlock.vue new file mode 100644 index 0000000..0540d74 --- /dev/null +++ b/frontend/src/components/CodeBlock.vue @@ -0,0 +1,44 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/router.js b/frontend/src/router.js index ee5ccba..ce556ba 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -5,9 +5,12 @@ import DeniedView from "./views/DeniedView.vue"; import OauthCallbackView from "./views/OauthCallbackView.vue"; import SourcesView from "./views/SourcesView.vue"; +import SourceDetailView from "./views/SourceDetailView.vue"; import TypesView from "./views/TypesView.vue"; import TargetsView from "./views/TargetsView.vue"; +import TargetDetailView from "./views/TargetDetailView.vue"; import RulesView from "./views/RulesView.vue"; +import RuleDetailView from "./views/RuleDetailView.vue"; import DeliveriesView from "./views/DeliveriesView.vue"; // Гейт ролей: /dashboard и далее — только system_role admin и выше. @@ -27,9 +30,12 @@ meta: { requiresAuth: true }, }, { path: "/sources", component: SourcesView, meta: { requiresAuth: true } }, + { path: "/sources/:id", component: SourceDetailView, meta: { requiresAuth: true } }, { path: "/types", component: TypesView, meta: { requiresAuth: true } }, { path: "/targets", component: TargetsView, meta: { requiresAuth: true } }, + { path: "/targets/:id", component: TargetDetailView, meta: { requiresAuth: true } }, { path: "/rules", component: RulesView, meta: { requiresAuth: true } }, + { path: "/rules/:id", component: RuleDetailView, meta: { requiresAuth: true } }, { path: "/deliveries", component: DeliveriesView, meta: { requiresAuth: true } }, ], }); diff --git a/frontend/src/ui.js b/frontend/src/ui.js index 21cee9c..4657335 100644 --- a/frontend/src/ui.js +++ b/frontend/src/ui.js @@ -1,4 +1,6 @@ /** Общие мелочи для админ-разделов: бейджи статусов, формат времени, сокращение. */ +import { h } from "vue"; +import { GnTooltip } from "gnexus-ui-kit/vue"; /** Разрешённые варианты кита: primary, secondary, accent, success, warning, danger, error, info. */ export const statusVariant = { @@ -17,11 +19,61 @@ return { label: status, variant: statusVariant[status] || "secondary" }; } +/** Вариант бейджа для канала: служебный internal_log нейтрален. */ +export function badgeChannel(channel) { + return channel === "internal_log" ? "secondary" : "accent"; +} + +// Время первым, как просил владелец: «03:44:35, 3 октября» (год — только не текущий). +const MONTHS = [ + "января", "февраля", "марта", "апреля", "мая", "июня", + "июля", "августа", "сентября", "октября", "ноября", "декабря", +]; export function fmtTime(iso) { if (!iso) return "—"; - return new Date(iso).toLocaleString("ru-RU", { - day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", - }); + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + const pad = (n) => String(n).padStart(2, "0"); + const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + const date = `${d.getDate()} ${MONTHS[d.getMonth()]}`; + const year = d.getFullYear() === new Date().getFullYear() ? "" : ` ${d.getFullYear()}`; + return `${time}, ${date}${year}`; +} + +const TOKEN_RE = /("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+(?:\.\d+)?)\b|(\btrue\b|\bfalse\b|\bnull\b)/g; + +/** + * Подсветка JSON: массив сегментов {cls, text} для CodeBlock — ключи, строки, + * числа, булевы/null окрашиваются классами; пунктуация остаётся базовой. + */ +export function jsonSpans(value) { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + const spans = []; + let last = 0; + for (const m of text.matchAll(TOKEN_RE)) { + const [full, str, colon, num, bool] = m; + const start = m.index; + if (start > last) spans.push({ cls: "", text: text.slice(last, start) }); + if (str !== undefined) { + // строка с двоеточием после неё — ключ объекта + spans.push({ cls: colon ? "code-key" : "code-string", text: full }); + } else if (num !== undefined) { + spans.push({ cls: "code-number", text: num }); + } else if (bool !== undefined) { + spans.push({ cls: "code-boolean", text: bool }); + } + last = start + full.length; + } + if (last < text.length) spans.push({ cls: "", text: text.slice(last) }); + return spans; +} + +/** + * Заголовок колонки GnTable с тултипом кита: GnTable рендерит column.label + * как есть, поэтому передаём VNode с GnTooltip вокруг подписи. + */ +export function th(label, text) { + return h(GnTooltip, { text }, () => label); } export function truncate(text, n = 80) { diff --git a/frontend/src/views/DeliveriesView.vue b/frontend/src/views/DeliveriesView.vue index e46df4f..c13059e 100644 --- a/frontend/src/views/DeliveriesView.vue +++ b/frontend/src/views/DeliveriesView.vue @@ -3,7 +3,6 @@ import { onMounted, ref } from "vue"; import { GnBadge, - GnButton, GnIconButton, GnLoader, GnPageHeader, @@ -13,7 +12,7 @@ } from "gnexus-ui-kit/vue"; import { useToast } from "gnexus-ui-kit/vue"; import { api } from "../api.js"; -import { badgeFor, fmtTime, truncate } from "../ui.js"; +import { badgeFor, fmtTime, th, truncate } from "../ui.js"; const toast = useToast(); @@ -64,13 +63,14 @@ kicker="Synapse" title="Доставки" subtitle="Лог попыток: канал, цель, статус, ретраи и текст, реально ушедший в канал" - > - - + /> + + +
+ + + +
@@ -80,11 +80,11 @@ empty-text="Доставок ещё не было" :columns="[ { key: 'created_at', label: 'Время' }, - { key: 'event', label: 'Событие' }, + { key: 'event', label: th('Событие', 'Идентификатор события, породившего доставку') }, { key: 'channel', label: 'Канал' }, { key: 'target_name', label: 'Цель' }, - { key: 'status', label: 'Статус' }, - { key: 'attempts', 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: 'Сообщение' }, ]" @@ -115,6 +115,14 @@ flex-direction: column; gap: var(--gn-space-4); } +.filters { + display: flex; + align-items: flex-end; + gap: var(--gn-space-4); +} +.filters > :nth-child(3) { + margin-bottom: var(--gn-space-3); +} .event-id { font-size: 0.78em; } diff --git a/frontend/src/views/RuleDetailView.vue b/frontend/src/views/RuleDetailView.vue new file mode 100644 index 0000000..9279659 --- /dev/null +++ b/frontend/src/views/RuleDetailView.vue @@ -0,0 +1,311 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/views/RulesView.vue b/frontend/src/views/RulesView.vue index 8fe364b..d04075e 100644 --- a/frontend/src/views/RulesView.vue +++ b/frontend/src/views/RulesView.vue @@ -19,10 +19,12 @@ GnTextarea, } from "gnexus-ui-kit/vue"; import { useToast } from "gnexus-ui-kit/vue"; +import { useRouter } from "vue-router"; import { api } from "../api.js"; -import { parseList, shortJson, truncate } from "../ui.js"; +import { parseList, shortJson, th, truncate } from "../ui.js"; const toast = useToast(); +const router = useRouter(); const rows = ref([]); const sources = ref([]); @@ -42,6 +44,11 @@ label: c, })); +const WEIGHT_TIP = + "Вес определяет порядок применения правил в режиме «первое подошедшее» (ROUTING_MATCH_MODE=first): меньше важнее, срабатывает раньше. В режиме «все» на результат не влияет."; +const THROTTLE_TIP = + "Шумодав: не чаще одной живой доставки в эту цель за N секунд. Подавленное отмечается статусом skipped, critical проходит всегда."; + async function load() { loading.value = true; error.value = ""; @@ -179,16 +186,18 @@ empty-text="Правил нет — событие, не попавшее ни в одно правило, только логируется историей" :columns="[ { key: 'name', label: 'Правило' }, - { key: 'weight', label: 'W' }, - { key: 'conditions', label: 'Условия' }, - { key: 'throttle_seconds', label: 'Шумодав' }, - { key: 'actionsList', label: 'Действия' }, + { key: 'weight', label: th('Вес', WEIGHT_TIP) }, + { key: 'conditions', label: th('Условия', 'Source, subjects, actions, priority_min и требования к payload — правило применяется, только если всё совпало') }, + { key: 'throttle_seconds', label: th('Шумодав', THROTTLE_TIP) }, + { key: 'actionsList', label: th('Действия', 'Куда доставить: канал и цель. internal_log без цели пишет журнал Synapse') }, { key: 'enabled', label: 'Вкл./выкл.' }, { key: 'actions', label: '' }, ]" > @@ -214,8 +226,8 @@
- - + +
Условия
@@ -325,6 +337,15 @@ font-size: 0.78em; word-break: break-all; } +.rule-link, +.cond-link { + color: inherit; + text-decoration: none; +} +.rule-link:hover, +.cond-link:hover .cond { + text-decoration: underline; +} .chips { display: flex; flex-wrap: wrap; diff --git a/frontend/src/views/SourceDetailView.vue b/frontend/src/views/SourceDetailView.vue new file mode 100644 index 0000000..15ae388 --- /dev/null +++ b/frontend/src/views/SourceDetailView.vue @@ -0,0 +1,286 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/views/SourcesView.vue b/frontend/src/views/SourcesView.vue index 64b6787..17886dc 100644 --- a/frontend/src/views/SourcesView.vue +++ b/frontend/src/views/SourcesView.vue @@ -1,12 +1,10 @@ @@ -120,7 +81,7 @@ - - - - - - - - - - - - Токен больше не будет показан: в Synapse хранится только sha256-хэш. - Скопируйте сейчас и отдайте владельцу источника. - -

{{ tokenModal.key.token }}

- -
- \ No newline at end of file diff --git a/frontend/src/views/TargetDetailView.vue b/frontend/src/views/TargetDetailView.vue new file mode 100644 index 0000000..0e4a47e --- /dev/null +++ b/frontend/src/views/TargetDetailView.vue @@ -0,0 +1,183 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/views/TargetsView.vue b/frontend/src/views/TargetsView.vue index 18daab5..4f06f9c 100644 --- a/frontend/src/views/TargetsView.vue +++ b/frontend/src/views/TargetsView.vue @@ -1,9 +1,8 @@