fix(traffic): спрашивать endpoint MM в sheet подключения JH
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Failing after 2m38s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Skipped
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Failing after 2m38s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Skipped
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -16,12 +16,7 @@ import {
|
|||||||
startTrafficFlowListener,
|
startTrafficFlowListener,
|
||||||
} from "../services/traffic-flow-ingest.js"
|
} from "../services/traffic-flow-ingest.js"
|
||||||
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
||||||
import {
|
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
|
||||||
buildHostComposeSnippet,
|
|
||||||
buildHostNftSnippet,
|
|
||||||
buildHostUfwSnippet,
|
|
||||||
buildHostWgQuickConf,
|
|
||||||
} from "../services/traffic-flow-host-files.js"
|
|
||||||
|
|
||||||
function rangeToMinutes(range: string | undefined): number {
|
function rangeToMinutes(range: string | undefined): number {
|
||||||
switch ((range ?? "5m").toLowerCase()) {
|
switch ((range ?? "5m").toLowerCase()) {
|
||||||
@@ -39,13 +34,22 @@ async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
|
|||||||
return reply.send(listFlowTalkers(rangeToMinutes(q.range)))
|
return reply.send(listFlowTalkers(rangeToMinutes(q.range)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestPublicHost(req: FastifyRequest): string {
|
||||||
|
const forwarded = req.headers["x-forwarded-host"]
|
||||||
|
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
|
||||||
|
return raw || req.hostname || ""
|
||||||
|
}
|
||||||
|
|
||||||
async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
|
async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
|
||||||
const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {})
|
const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await applyFlowOverlay(parsed.data.serverId)
|
const result = await applyFlowOverlay(parsed.data.serverId, {
|
||||||
|
publicEndpoint: parsed.data.publicEndpoint,
|
||||||
|
requestHost: requestPublicHost(req),
|
||||||
|
})
|
||||||
return reply.send(result)
|
return reply.send(result)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = (e as { statusCode?: number }).statusCode ?? 502
|
const status = (e as { statusCode?: number }).statusCode ?? 502
|
||||||
@@ -82,14 +86,7 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
app.get("/traffic/flow/host-files", async (_req, reply) => {
|
app.get("/traffic/flow/host-files", async (_req, reply) => {
|
||||||
const row = getTrafficFlowSettingsRow()
|
const row = getTrafficFlowSettingsRow()
|
||||||
if (!row.hostPrivateKey) ensureHostKeys()
|
if (!row.hostPrivateKey) ensureHostKeys()
|
||||||
return reply.send({
|
return reply.send({ files: listTrafficFlowHostFiles() })
|
||||||
files: [
|
|
||||||
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
|
||||||
{ id: "compose", label: "docker-compose", filename: "docker-compose.flow.yml", code: buildHostComposeSnippet() },
|
|
||||||
{ id: "nft", label: "nftables", filename: "wg-flow.nft", code: buildHostNftSnippet() },
|
|
||||||
{ id: "ufw", label: "ufw", filename: "wg-flow.ufw.sh", code: buildHostUfwSnippet() },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/traffic/flow/overlay", applyOverlayHandler)
|
app.post("/traffic/flow/overlay", applyOverlayHandler)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { generateNativeConf } from "./wireguard-config.js"
|
import { generateNativeConf } from "./wireguard-config.js"
|
||||||
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||||
|
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
||||||
|
|
||||||
export function buildHostWgQuickConf(): string {
|
export function buildHostWgQuickConf(): string {
|
||||||
const row = getTrafficFlowSettingsRow()
|
const row = getTrafficFlowSettingsRow()
|
||||||
@@ -58,3 +59,12 @@ export function buildHostUfwSnippet(): string {
|
|||||||
`ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`,
|
`ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
|
||||||
|
return [
|
||||||
|
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
||||||
|
{ id: "compose", label: "docker-compose", filename: "docker-compose.flow.yml", code: buildHostComposeSnippet() },
|
||||||
|
{ id: "nft", label: "nftables", filename: "wg-flow.nft", code: buildHostNftSnippet() },
|
||||||
|
{ id: "ufw", label: "ufw", filename: "wg-flow.ufw.sh", code: buildHostUfwSnippet() },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
ensureHostKeys,
|
ensureHostKeys,
|
||||||
getTrafficFlowSettingsRow,
|
getTrafficFlowSettingsRow,
|
||||||
|
updateTrafficFlowSettings,
|
||||||
upsertHostPeer,
|
upsertHostPeer,
|
||||||
} from "./traffic-flow-settings.js"
|
} from "./traffic-flow-settings.js"
|
||||||
|
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
||||||
|
|
||||||
const IFACE_NAME = "wg-flow"
|
const IFACE_NAME = "wg-flow"
|
||||||
const JH_LISTEN_PORT = 13232
|
const JH_LISTEN_PORT = 13232
|
||||||
@@ -136,15 +138,35 @@ async function ensureTrafficFlow(client: MikrotikClient, collectorIp: string, po
|
|||||||
await client.put("/ip/traffic-flow/target", body)
|
await client.put("/ip/traffic-flow/target", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyFlowOverlay(serverIdRaw: string | number): Promise<TrafficFlowOverlayResult> {
|
export function usablePublicHost(raw: string | undefined): string {
|
||||||
|
if (!raw) return ""
|
||||||
|
const host = raw.split(",")[0]?.trim().replace(/^\[/, "").replace(/\]:\d+$/, "").split(":")[0]?.trim() ?? ""
|
||||||
|
const lower = host.toLowerCase()
|
||||||
|
if (!host) return ""
|
||||||
|
if (lower === "localhost" || lower === "127.0.0.1" || lower === "::1" || lower === "0.0.0.0") return ""
|
||||||
|
if (lower.endsWith(".local") || lower.endsWith(".internal") || lower.endsWith(".lan")) return ""
|
||||||
|
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return ""
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyFlowOverlay(
|
||||||
|
serverIdRaw: string | number,
|
||||||
|
opts?: { publicEndpoint?: string; requestHost?: string },
|
||||||
|
): Promise<TrafficFlowOverlayResult> {
|
||||||
const steps: string[] = []
|
const steps: string[] = []
|
||||||
const settings = getTrafficFlowSettingsRow()
|
|
||||||
const keys = ensureHostKeys()
|
const keys = ensureHostKeys()
|
||||||
if (!settings.hostPublicKey && !keys.publicKey) {
|
let settings = getTrafficFlowSettingsRow()
|
||||||
throw Object.assign(new Error("Сначала сгенерируйте ключи хоста MM в настройках NetFlow"), { statusCode: 400 })
|
|
||||||
}
|
|
||||||
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
||||||
if (!settings.publicEndpoint.trim()) {
|
if (!hostPublicKey) {
|
||||||
|
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointHost = (
|
||||||
|
opts?.publicEndpoint?.trim()
|
||||||
|
|| settings.publicEndpoint.trim()
|
||||||
|
|| usablePublicHost(opts?.requestHost)
|
||||||
|
).trim()
|
||||||
|
if (!endpointHost) {
|
||||||
throw Object.assign(new Error("Укажите публичный endpoint хоста MM (IP или DNS)"), { statusCode: 400 })
|
throw Object.assign(new Error("Укажите публичный endpoint хоста MM (IP или DNS)"), { statusCode: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +175,11 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise<Tr
|
|||||||
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
|
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (endpointHost !== settings.publicEndpoint.trim()) {
|
||||||
|
updateTrafficFlowSettings({ publicEndpoint: endpointHost })
|
||||||
|
settings = getTrafficFlowSettingsRow()
|
||||||
|
}
|
||||||
|
|
||||||
const taken = new Set(
|
const taken = new Set(
|
||||||
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
|
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
|
||||||
.map((r) => r.ip)
|
.map((r) => r.ip)
|
||||||
@@ -187,7 +214,6 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise<Tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const peer = await findPeer(client, IFACE_NAME, hostPublicKey)
|
const peer = await findPeer(client, IFACE_NAME, hostPublicKey)
|
||||||
const endpointHost = settings.publicEndpoint.trim()
|
|
||||||
const peerBody = {
|
const peerBody = {
|
||||||
interface: IFACE_NAME,
|
interface: IFACE_NAME,
|
||||||
"public-key": hostPublicKey,
|
"public-key": hostPublicKey,
|
||||||
@@ -258,6 +284,7 @@ export async function applyFlowOverlay(serverIdRaw: string | number): Promise<Tr
|
|||||||
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host),
|
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host),
|
||||||
trafficFlow: true,
|
trafficFlow: true,
|
||||||
steps,
|
steps,
|
||||||
|
hostFiles: listTrafficFlowHostFiles(),
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||||
|
|||||||
@@ -31,4 +31,11 @@ const taken = new Set(["10.255.254.2"])
|
|||||||
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 1, taken), "10.255.254.3")
|
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 1, taken), "10.255.254.3")
|
||||||
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3")
|
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3")
|
||||||
|
|
||||||
|
import { usablePublicHost } from "./traffic-flow-overlay.js"
|
||||||
|
assert.equal(usablePublicHost("localhost:8000"), "")
|
||||||
|
assert.equal(usablePublicHost("127.0.0.1"), "")
|
||||||
|
assert.equal(usablePublicHost("192.168.1.10"), "")
|
||||||
|
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
|
||||||
|
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
|
||||||
|
|
||||||
console.log("traffic-flow-parse.test.ts: ok")
|
console.log("traffic-flow-parse.test.ts: ok")
|
||||||
|
|||||||
@@ -3,15 +3,25 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { FormField } from "@/components/form-kit"
|
import { FormField } from "@/components/form-kit"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { downloadText } from "@/components/reui-kit/code-export-sheet"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { CopyIcon } from "lucide-react"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
import { applyTrafficFlowOverlay, getTrafficFlowSettings } from "@/shared/api/traffic-flow"
|
||||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
import type { TrafficFlowHostFile, TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
InfoIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
function FlowOverlaySheet({
|
function FlowOverlaySheet({
|
||||||
open,
|
open,
|
||||||
@@ -31,21 +41,46 @@ function FlowOverlaySheet({
|
|||||||
[servers],
|
[servers],
|
||||||
)
|
)
|
||||||
const [serverId, setServerId] = useState("")
|
const [serverId, setServerId] = useState("")
|
||||||
|
const [endpoint, setEndpoint] = useState("")
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
|
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [tab, setTab] = useState("wg-quick")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setResult(null)
|
setResult(null)
|
||||||
|
setCopied(false)
|
||||||
|
setTab("wg-quick")
|
||||||
setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "")
|
setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "")
|
||||||
}, [open, jumpHosts])
|
void getTrafficFlowSettings(backendUrl)
|
||||||
|
.then((s) => setEndpoint(s.publicEndpoint))
|
||||||
|
.catch(() => setEndpoint(""))
|
||||||
|
}, [open, jumpHosts, backendUrl])
|
||||||
|
|
||||||
|
const formats = useMemo((): TrafficFlowHostFile[] => {
|
||||||
|
if (!result) return []
|
||||||
|
return [
|
||||||
|
...result.hostFiles,
|
||||||
|
{
|
||||||
|
id: "peer",
|
||||||
|
label: "[Peer]",
|
||||||
|
filename: "wg-flow-peer.conf",
|
||||||
|
code: result.linuxPeerBlock,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [result])
|
||||||
|
|
||||||
|
const active = formats.find((f) => f.id === tab) ?? formats[0]
|
||||||
|
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!serverId) return
|
if (!serverId || !endpoint.trim()) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await applyTrafficFlowOverlay(backendUrl, serverId)
|
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
|
||||||
setResult(res)
|
setResult(res)
|
||||||
|
setTab(res.hostFiles[0]?.id ?? "peer")
|
||||||
toast.success(`wg-flow на ${res.address}`)
|
toast.success(`wg-flow на ${res.address}`)
|
||||||
onDone?.(res)
|
onDone?.(res)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -55,16 +90,26 @@ function FlowOverlaySheet({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
const code = active?.code ?? ""
|
||||||
|
if (!code) return
|
||||||
|
void navigator.clipboard.writeText(code).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
toast.success("Скопировано")
|
||||||
|
setTimeout(() => setCopied(false), 1800)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Подключить jump-host</SheetTitle>
|
<SheetTitle>Подключить jump-host</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Создать wg-flow на выбранном MikroTik и направить Traffic Flow на collector MM. Хост Docker уже должен слушать WireGuard.
|
Настроит wg-flow и Traffic Flow на MikroTik и сразу выдаст файлы для Linux-хоста Docker MM (wg-quick / compose / firewall).
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
<FormField label="Jump-host" required>
|
<FormField label="Jump-host" required>
|
||||||
<select
|
<select
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
|
||||||
@@ -79,35 +124,81 @@ function FlowOverlaySheet({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
label="Публичный IP или DNS хоста Docker MM"
|
||||||
|
required
|
||||||
|
hint="Откуда JH стучится на WG listen (51821). Хост с wg-quick, не контейнер backend."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
value={endpoint}
|
||||||
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
|
placeholder="203.0.113.10"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
{result ? (
|
{result ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-4 min-h-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<Alert>
|
||||||
<p className="text-xs text-muted-foreground">Пир для хоста MM (`wg set` или допишите conf):</p>
|
<InfoIcon />
|
||||||
<Button
|
<AlertTitle>Ключи и UDP 4739</AlertTitle>
|
||||||
type="button"
|
<AlertDescription>
|
||||||
size="sm"
|
Приватный ключ хоста в SQLite панели — не кладите в git. UDP 4739 публикуйте только на WG-IP, не на 0.0.0.0.
|
||||||
variant="outline"
|
</AlertDescription>
|
||||||
onClick={() => {
|
</Alert>
|
||||||
void navigator.clipboard.writeText(result.linuxPeerBlock)
|
|
||||||
toast.success("Скопировано")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CopyIcon className="size-3.5" />
|
|
||||||
Копировать
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<pre className="text-[11px] font-mono bg-muted/40 border rounded-md p-3 whitespace-pre-wrap">{result.linuxPeerBlock}</pre>
|
|
||||||
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
|
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
|
||||||
{result.steps.map((s) => (
|
{result.steps.map((s) => (
|
||||||
<li key={s}>{s}</li>
|
<li key={s}>{s}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{formats.length > 0 && active ? (
|
||||||
|
<div className="flex flex-col gap-3 min-h-0">
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setTab(String(v))
|
||||||
|
setCopied(false)
|
||||||
|
}}
|
||||||
|
className="shrink-0 gap-0"
|
||||||
|
>
|
||||||
|
<TabsList className="h-9 w-full">
|
||||||
|
{formats.map((f) => (
|
||||||
|
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-1.5 text-xs sm:text-sm">
|
||||||
|
{f.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<Frame dense className="flex min-h-0 flex-col">
|
||||||
|
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
|
||||||
|
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all min-h-[12rem]">
|
||||||
|
{active.code}
|
||||||
|
</pre>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => downloadText(active.filename, active.code)}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-3.5" />
|
||||||
|
Файл
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={handleCopy}>
|
||||||
|
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||||
|
{copied ? "Скопировано" : "Копировать"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" />}>Закрыть</SheetClose>
|
<SheetClose render={<Button variant="outline" />}>Закрыть</SheetClose>
|
||||||
<Button disabled={!serverId || busy} onClick={() => { void handleSubmit() }}>
|
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
|
||||||
{busy ? "Подключение…" : "Подключить"}
|
{busy ? "Подключение…" : "Подключить"}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export const trafficFlowSettingsPatchSchema = z.object({
|
|||||||
|
|
||||||
export const trafficFlowOverlayRequestSchema = z.object({
|
export const trafficFlowOverlayRequestSchema = z.object({
|
||||||
serverId: z.union([z.string(), z.number()]),
|
serverId: z.union([z.string(), z.number()]),
|
||||||
|
publicEndpoint: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const trafficFlowHostFileSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
filename: z.string(),
|
||||||
|
code: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const trafficFlowOverlayResultSchema = z.object({
|
export const trafficFlowOverlayResultSchema = z.object({
|
||||||
@@ -54,6 +62,7 @@ export const trafficFlowOverlayResultSchema = z.object({
|
|||||||
linuxPeerBlock: z.string(),
|
linuxPeerBlock: z.string(),
|
||||||
trafficFlow: z.boolean(),
|
trafficFlow: z.boolean(),
|
||||||
steps: z.array(z.string()),
|
steps: z.array(z.string()),
|
||||||
|
hostFiles: z.array(trafficFlowHostFileSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const flowTalkerDtoSchema = z.object({
|
export const flowTalkerDtoSchema = z.object({
|
||||||
@@ -84,5 +93,6 @@ export type FlowHostPeer = z.infer<typeof flowHostPeerSchema>
|
|||||||
export type TrafficFlowSettingsDto = z.infer<typeof trafficFlowSettingsDtoSchema>
|
export type TrafficFlowSettingsDto = z.infer<typeof trafficFlowSettingsDtoSchema>
|
||||||
export type TrafficFlowSettingsPatch = z.infer<typeof trafficFlowSettingsPatchSchema>
|
export type TrafficFlowSettingsPatch = z.infer<typeof trafficFlowSettingsPatchSchema>
|
||||||
export type TrafficFlowOverlayResult = z.infer<typeof trafficFlowOverlayResultSchema>
|
export type TrafficFlowOverlayResult = z.infer<typeof trafficFlowOverlayResultSchema>
|
||||||
|
export type TrafficFlowHostFile = z.infer<typeof trafficFlowHostFileSchema>
|
||||||
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
|
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
|
||||||
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
|
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
|
||||||
|
|||||||
@@ -29,12 +29,7 @@ export async function generateTrafficFlowKeys(baseUrl: string): Promise<{
|
|||||||
return requestJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { method: "POST" })
|
return requestJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { method: "POST" })
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrafficFlowHostFile = {
|
export type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
filename: string
|
|
||||||
code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files: TrafficFlowHostFile[] }> {
|
export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files: TrafficFlowHostFile[] }> {
|
||||||
return requestJson(baseUrl, "/api/traffic/flow/host-files")
|
return requestJson(baseUrl, "/api/traffic/flow/host-files")
|
||||||
@@ -43,10 +38,11 @@ export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files:
|
|||||||
export async function applyTrafficFlowOverlay(
|
export async function applyTrafficFlowOverlay(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
serverId: string | number,
|
serverId: string | number,
|
||||||
|
publicEndpoint?: string,
|
||||||
): Promise<TrafficFlowOverlayResult> {
|
): Promise<TrafficFlowOverlayResult> {
|
||||||
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
|
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ serverId }),
|
body: JSON.stringify({ serverId, publicEndpoint }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user