Newer
Older
gn-synapse / frontend / src / views / RulesView.vue
<script setup>
// Правила маршрутизации: условия → действия (каналы/цели/шаблоны), шумодав.
// Switch в таблице мгновенно включает/выключает правило (PATCH).
import { onMounted, ref } from "vue";
import {
  GnBadge,
  GnButton,
  GnChip,
  GnConfirmDialog,
  GnIconButton,
  GnInput,
  GnLoader,
  GnModal,
  GnPageHeader,
  GnSelect,
  GnStatusCard,
  GnSwitch,
  GnTable,
  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, th, truncate } from "../ui.js";

const toast = useToast();
const router = useRouter();

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

const PRIORITIES = [
  { value: "", label: "— любая —" },
  { value: "low", label: "low" },
  { value: "normal", label: "normal" },
  { value: "high", label: "high" },
  { value: "critical", label: "critical" },
];
const SOURCE_CHANNEL_OPTIONS = ["telegram", "email", "s2s", "internal_log"].map((c) => ({
  value: c,
  label: c,
}));

const WEIGHT_TIP =
  "Вес определяет порядок применения правил в режиме «первое подошедшее» (ROUTING_MATCH_MODE=first): меньше важнее, срабатывает раньше. В режиме «все» на результат не влияет.";
const THROTTLE_TIP =
  "Шумодав: не чаще одной живой доставки в эту цель за N секунд. Подавленное отмечается статусом skipped, critical проходит всегда.";

async function load() {
  loading.value = true;
  error.value = "";
  try {
    const [rules, src, tg] = await Promise.all([api.listRules(), api.listSources(), api.listTargets()]);
    rows.value = rules;
    sources.value = src;
    targets.value = tg;
  } catch (ex) {
    error.value = ex.message;
  } finally {
    loading.value = false;
  }
}

const showCreate = ref(false);
const busy = ref(false);
const emptyRule = () => ({
  name: "",
  weight: 0,
  throttle_seconds: "",
  source: "",
  subjects: "",
  actions: "",
  priority: "",
  payload: "",
  template: "",
  actionRows: [{ channel: "internal_log", target_id: "", template: "" }],
});
const form = ref(emptyRule());
const formError = ref("");

const sourceOptions = [{ value: "", label: "— любой источник —" }];

const actionsSummary = (rule) =>
  rule.actions.map((a) => `${a.channel}${a.target_name ? ":" + a.target_name : ""}`).join(", ");

function addActionRow() {
  form.value.actionRows.push({ channel: "internal_log", target_id: "", template: "" });
}
function removeActionRow(i) {
  form.value.actionRows.splice(i, 1);
}

async function createRule() {
  formError.value = "";
  let payload_condition = {};
  try {
    payload_condition = form.value.payload.trim() ? JSON.parse(form.value.payload) : null;
  } catch {
    formError.value = "Требования payload — не корректный JSON";
    return;
  }
  const actions = form.value.actionRows
    .filter((a) => a.channel)
    .map((a) => ({
      channel: a.channel,
      ...(a.target_id ? { target_id: Number(a.target_id) } : {}),
      ...(a.template ? { template: a.template } : {}),
    }));
  if (!actions.length) {
    formError.value = "Добавьте хотя бы одно действие";
    return;
  }
  busy.value = true;
  try {
    await api.createRule({
      name: form.value.name.trim(),
      weight: Number(form.value.weight) || 0,
      ...(form.value.throttle_seconds ? { throttle_seconds: Number(form.value.throttle_seconds) } : {}),
      conditions: {
        ...(form.value.source ? { source: form.value.source } : {}),
        ...(parseList(form.value.subjects).length ? { subjects: parseList(form.value.subjects) } : {}),
        ...(parseList(form.value.actions).length ? { actions: parseList(form.value.actions) } : {}),
        ...(form.value.priority ? { priority_min: form.value.priority } : {}),
        ...(payload_condition ? { payload: payload_condition } : {}),
      },
      ...(form.value.template ? { template: form.value.template } : {}),
      actions,
    });
    showCreate.value = false;
    form.value = emptyRule();
    toast.success("Правило создано");
    await load();
  } catch (ex) {
    toast.error(ex.message);
  } finally {
    busy.value = false;
  }
}

async function toggleRule(rule, enabled) {
  try {
    await api.patchRule(rule.id, { enabled });
    toast.info(`Правило ${enabled ? "включено" : "выключено"}`);
  } catch (ex) {
    toast.error(ex.message);
    await load();
  }
}

const confirmDelete = ref(null);
async function doDelete() {
  try {
    await api.deleteRule(confirmDelete.value.id);
    toast.success("Правило удалено");
    await load();
  } catch (ex) {
    toast.error(ex.message);
  } finally {
    confirmDelete.value = null;
  }
}

onMounted(load);
</script>

<template>
  <main class="container page">
    <GnPageHeader
      kicker="Synapse"
      title="Правила"
      subtitle="Условия → действия. Все подошедшие правила применяются; шумодав режет повторные доставки, critical проходит всегда"
    >
      <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="rows"
        empty-text="Правил нет — событие, не попавшее ни в одно правило, только логируется историей"
        :columns="[
          { key: 'name', label: 'Правило' },
          { key: 'weight', label: th('Вес', WEIGHT_TIP) },
          { key: 'throttle_seconds', label: th('Шумодав', THROTTLE_TIP) },
          { key: 'actionsList', label: th('Действия', 'Куда доставить: канал и цель. internal_log без цели пишет журнал Synapse. Условия — на странице правила') },
          { key: 'enabled', label: 'Вкл./выкл.' },
          { key: 'actions', label: '' },
        ]"
      >
        <template #cell-name="{ row }">
          <RouterLink :to="`/rules/${row.id}`" class="rule-link">{{ row.name }}</RouterLink>
        </template>
        <template #cell-throttle_seconds="{ row }">
          <GnBadge v-if="row.throttle_seconds" :variant="'warning'" outline>{{ row.throttle_seconds }} с</GnBadge>
          <span v-else>—</span>
        </template>
        <template #cell-actionsList="{ row }">
          <div class="chips">
            <GnChip v-for="(a, i) in row.actions" :key="i" variant="accent">
              {{ a.channel }}{{ a.target_name ? ":" + a.target_name : "" }}
            </GnChip>
          </div>
        </template>
        <template #cell-enabled="{ row }">
          <GnSwitch :model-value="row.enabled" @update:model-value="(v) => toggleRule(row, v)" />
        </template>
        <template #cell-actions="{ row }">
          <GnIconButton icon="ph-trash" size="sm" label="Удалить правило" @click="confirmDelete = row" />
        </template>
      </GnTable>
    </template>

    <GnModal v-model:open="showCreate" title="Новое правило" class="rules-modal">
      <div class="form">
        <GnInput v-model="form.name" label="Название" icon="ph-funnel" />
        <div class="row">
          <GnInput v-model="form.weight" label="Вес (weight)" icon="ph-sort-descending" :help="WEIGHT_TIP" />
          <GnInput v-model="form.throttle_seconds" label="Шумодав, сек" icon="ph-timer" :help="THROTTLE_TIP" />
        </div>

        <div class="conditions-title">Условия</div>
        <div class="row">
          <GnSelect
            v-model="form.source"
            label="Источник"
            :options="sourceOptions.concat(sources.map((s) => ({ value: s.name, label: s.name })))"
          />
          <GnSelect v-model="form.priority" label="Priority ≥" :options="PRIORITIES" />
        </div>
        <GnInput v-model="form.subjects" label="Subjects" icon="ph-shapes" help="Через запятую; пусто = любые" />
        <GnInput v-model="form.actions" label="Actions" icon="ph-lightning" help="Через запятую; пусто = любые" />
        <GnTextarea v-model="form.payload" label="Требования к payload (JSON)" help='Например {"host": ["melody","pilar"]}; пусто = без требования' />

        <div class="conditions-title">Действия</div>
        <div class="row">
          <GnInput v-model="form.template" label="Шаблон по умолчанию (Jinja2)" icon="ph-file-text" help='Например: Контейнер {{ payload.container }} упал на {{ payload.host }}' />
        </div>
        <div v-for="(a, i) in form.actionRows" :key="i" class="action-row">
          <GnSelect v-model="a.channel" label="Канал" :options="SOURCE_CHANNEL_OPTIONS" />
          <GnSelect
            v-model="a.target_id"
            label="Цель"
            :options="[
              { value: '', label: '— без цели —' },
              ...targets.filter((t) => t.channel === a.channel).map((t) => ({ value: t.id, label: t.name })),
            ]"
          />
          <GnIconButton icon="ph-x" label="Убрать действие" @click="removeActionRow(i)" />
        </div>
        <GnButton variant="secondary" size="sm" icon="ph-plus" @click="addActionRow">Действие</GnButton>

        <GnStatusCard
          v-if="formError"
          variant="error"
          icon="ph-warning-circle"
          title="Ошибка формы"
          :text="formError"
        />
      </div>
      <template #actions>
        <GnButton variant="secondary" @click="showCreate = false">Отмена</GnButton>
        <GnButton variant="primary" :loading="busy" :disabled="!form.name.trim()" @click="createRule">
          Создать
        </GnButton>
      </template>
    </GnModal>

    <GnConfirmDialog
      :open="confirmDelete !== null" @update:open="(v) => (v ? null : (confirmDelete = null))"
      :key="confirmDelete?.id"
      title="Удалить правило?"
      :message="confirmDelete ? truncate(`Правило «${confirmDelete.name}» будет удалено вместе с действиями. История доставок останется.`, 200) : ''"
      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);
}
.form {
  display: flex;
  flex-direction: column;
  gap: var(--gn-space-4);
  min-width: min(640px, 80vw);
}
.row {
  display: flex;
  flex-direction: row;
  gap: var(--gn-space-4);
}
.row > :first-child {
  flex: 1;
}
.row > :not(:first-child) {
  flex: 1;
}
.conditions-title {
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-size: 0.8em;
}
.action-row {
  display: flex;
  gap: var(--gn-space-3);
  align-items: flex-end;
}
.action-row > :first-child {
  min-width: 140px;
}
.action-row > :nth-child(2) {
  flex: 1;
}
.rule-link {
  color: inherit;
  text-decoration: none;
}
.rule-link:hover {
  text-decoration: underline;
}
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gn-space-1);
}
</style>