fix(config): добавить новые зависимости и обновить конфигурацию компонентов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s

This commit is contained in:
Denozordec
2026-06-30 21:30:40 +07:00
parent 009011a917
commit a1a9124f3d
80 changed files with 13310 additions and 1587 deletions
+47 -98
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
@@ -138,58 +139,6 @@ function IpsecBadge({ secured }: { secured: boolean }) {
)
}
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
// ─── Live API (как на /filters) ─────────────────────────────────────────────
interface BackendServer {
@@ -838,51 +787,51 @@ export default function GrePage() {
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
</Field>
<Field label="Сервер (MikroTik)" required>
</FormField>
<FormField label="Сервер (MikroTik)" required>
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать сервер…</option>
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
</select>
</Field>
<Field label="Комментарий">
</FormField>
<FormField label="Комментарий">
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
</Field>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
<FormToggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Эндпоинты</SectionTitle>
<Field label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<FormField label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
</Field>
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
</FormField>
<FormField label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
</Field>
</FormField>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Внутренний IP</SectionTitle>
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать пул…</option>
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
</select>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Локальный IP" required hint="/ip address на этом конце">
<FormField label="Локальный IP" required hint="/ip address на этом конце">
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
</Field>
<Field label="Удалённый IP" required hint="/ip address на другом конце">
</FormField>
<FormField label="Удалённый IP" required hint="/ip address на другом конце">
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
</Field>
</FormField>
</div>
</div>
@@ -893,12 +842,12 @@ export default function GrePage() {
<p className="text-sm font-medium">Включить IPsec</p>
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
</div>
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
<FormToggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
</div>
{tForm.ipsecEnabled && (
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
<Field label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<FormField label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<div className="relative">
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
@@ -907,38 +856,38 @@ export default function GrePage() {
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</Field>
<Field label="IKE-версия">
</FormField>
<FormField label="IKE-версия">
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Шифрование">
<FormField label="Шифрование">
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label="Хеш-алгоритм">
</FormField>
<FormField label="Хеш-алгоритм">
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</FormField>
</div>
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<FormField label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</FormField>
<div className="grid grid-cols-2 gap-3">
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<FormField label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
</Field>
</FormField>
<div className="flex items-center justify-between pt-6">
<span className="text-sm font-medium">PFS</span>
<Toggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
<FormToggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
</div>
</div>
</div>
@@ -955,23 +904,23 @@ export default function GrePage() {
{tForm.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
<Field label="MTU" hint="По умолч. 1476">
<FormField label="MTU" hint="По умолч. 1476">
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
</Field>
<Field label="Keepalive, с" hint="0 = откл.">
</FormField>
<FormField label="Keepalive, с" hint="0 = откл.">
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
</Field>
<Field label="Попытки">
</FormField>
<FormField label="Попытки">
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
</Field>
</FormField>
</div>
<Field label="DSCP">
<FormField label="DSCP">
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="inherit">inherit</option>
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
</select>
</Field>
</FormField>
{[
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
@@ -981,7 +930,7 @@ export default function GrePage() {
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<Toggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
<FormToggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
</div>
))}
</div>
@@ -1006,18 +955,18 @@ export default function GrePage() {
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Параметры пула</SectionTitle>
<Field label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<FormField label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
</FormField>
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
</Field>
<Field label="Назначение / Комментарий">
</FormField>
<FormField label="Назначение / Комментарий">
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
</Field>
</FormField>
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0