Newer
Older
gn-synapse / frontend / src / components / RuleForm.vue
<!-- Форма правила маршрутизации: общая для создания (RulesView) и
     правки (RuleDetailView). Родитель монтирует её в модали, а по кнопке
     сохранения вызывает collect(): он соберёт JSON для POST/PATCH или
     заполнит formError и вернёт null. -->
<script setup>
import { ref } from "vue";
import {
  GnButton,
  GnChip,
  GnIconButton,
  GnInput,
  GnSelect,
  GnStatusCard,
  GnSwitch,
  GnTable,
  GnTextarea,
} from "gnexus-ui-kit/vue";
import { parseList } from "../ui.js";

const props = defineProps({
  // Источники и цели для селектов (из админ-списков)
  sources: { type: Array, default: () => [] },
  targets: { type: Array, default: () => [] },
  // Существующее правило — режим редактирования, поля предзаполнятся
  initial: { type: Object, default: null },
});

// --- состояние формы ---
const form = ref(props.initial ? fromRule(props.initial) : emptyForm());

function emptyForm() {
  return {
    name: "",
    weight: 0,
    throttle_seconds: "",
    source: "",
    subjects: "",
    actions: "",
    priority: "",
    payload: "",
    template: "",
    actionRows: [{ channel: "internal_log", target_id: "", template: "" }],
  };
}

// правило из API -> форма (для правки)
function fromRule(rule) {
  const cond = rule.conditions || {};
  return {
    name: rule.name || "",
    weight: rule.weight ?? 0,
    throttle_seconds: rule.throttle_seconds ?? "",
    source: cond.source || "",
    subjects: (cond.subjects || []).join(", "),
    actions: (cond.actions || []).join(", "),
    priority: cond.priority_min || "",
    payload: cond.payload ? JSON.stringify(cond.payload, null, 2) : "",
    template: rule.template || "",
    actionRows: (rule.actions || []).map((a) => ({
      channel: a.channel,
      target_id: a.target_id ?? "",
      template: a.template || "",
    })),
  };
}

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

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

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

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

/** Собрать JSON для POST/PATCH; при ошибке заполнить formError и вернуть null. */
function collect() {
  formError.value = "";
  if (!form.value.name.trim()) {
    formError.value = "Задайте название правила";
    return null;
  }
  let payload_condition = null;
  try {
    payload_condition = form.value.payload.trim() ? JSON.parse(form.value.payload) : null;
  } catch {
    formError.value = "Требования payload — не корректный JSON";
    return null;
  }
  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 null;
  }
  const 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 } : {}),
  };
  return {
    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.template ? { template: form.value.template } : {}),
    actions,
  };
}

defineExpose({ collect, formError });
</script>

<template>
  <div class="form">
    <GnInput v-model="form.name" label="Название" icon="ph-funnel" help="Как правило подписано в списке, например «контейнеры вниз»" />
    <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>
    <p class="section-note">
      Правило применяется к событию, только если все заданные условия совпали. Пустое условие = всегда подходит.
    </p>
    <div class="row">
      <GnSelect
        v-model="form.source"
        label="Источник"
        :options="sourceOptions.concat(props.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="Сущности через запятую: container, build…; пусто = любые" />
    <GnInput v-model="form.actions" label="Actions" icon="ph-lightning" help="Действия события через запятую: down, created…; пусто = любые" />
    <GnTextarea
      v-model="form.payload"
      label="Требования к payload (JSON)"
      help='Например {"host": ["melody","pilar"]} — событие должно нести host melody или pilar; пусто = без требования'
    />

    <div class="conditions-title">Действия</div>
    <p class="section-note">
      Что делать, когда правило совпало: создать доставку в каждом канале ниже. internal_log без цели — запись в
      журнал Synapse (аудит); user — личный лог пользователя из payload.user_id; s2s — подписанная отправка в
      сервис; telegram/email — каналы #29, пока не работают.
    </p>
    <div class="row">
      <GnInput v-model="form.template" label="Шаблон по умолчанию (Jinja2)" icon="ph-file-text" help='{{ payload.поле }} — значение из payload события, например "Контейнер {{ payload.container }} упал"; у действия может быть свой шаблон' />
    </div>
    <div v-for="(a, i) in form.actionRows" :key="i" class="action-row">
      <GnSelect v-model="a.channel" label="Канал" :options="CHANNELS" />
      <GnSelect
        v-model="a.target_id"
        label="Цель"
        :options="[
          { value: '', label: '— без цели —' },
          ...props.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>

<style scoped>
.form {
  display: flex;
  flex-direction: column;
  gap: var(--gn-space-4);
  min-width: min(640px, 80vw);
}
.row {
  display: flex;
  gap: var(--gn-space-4);
}
.row > * {
  flex: 1;
}
.conditions-title {
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-size: 0.8em;
}
.section-note {
  margin: calc(-1 * var(--gn-space-2)) 0 0;
  font-size: 0.82em;
  opacity: 0.75;
}
.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;
}
</style>