refactor(traffic): update traffic flow host file generation and configuration scripts
- Renamed and refactored functions for better clarity, including `buildHostComposeSnippet` to `buildHostComposeOverride` and `buildHostNftSnippet` to `buildHostLinuxInstallSh`. - Introduced a new script for Linux installation of WireGuard, enhancing the setup process for Docker hosts. - Updated the `generateNativeConf` function to conditionally include the listen port in the configuration. - Adjusted the `FlowOverlaySheet` component to default to the new Linux tab and improved the user interface for selecting jump-hosts. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -2,12 +2,13 @@ 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"
|
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
||||||
|
|
||||||
|
const COMPOSE_DIR = "/opt/cdn-mm"
|
||||||
|
|
||||||
export function buildHostWgQuickConf(): string {
|
export function buildHostWgQuickConf(): string {
|
||||||
const row = getTrafficFlowSettingsRow()
|
const row = getTrafficFlowSettingsRow()
|
||||||
const peers = listHostPeers()
|
const peers = listHostPeers()
|
||||||
return generateNativeConf({
|
return generateNativeConf({
|
||||||
name: "wg-flow",
|
name: "wg-flow",
|
||||||
listenPort: row.wgListenPort,
|
|
||||||
mtu: 1420,
|
mtu: 1420,
|
||||||
privateKey: row.hostPrivateKey || undefined,
|
privateKey: row.hostPrivateKey || undefined,
|
||||||
address: `${row.collectorIp}/24`,
|
address: `${row.collectorIp}/24`,
|
||||||
@@ -22,47 +23,92 @@ export function buildHostWgQuickConf(): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostComposeSnippet(): string {
|
export function buildHostComposeOverride(): string {
|
||||||
const row = getTrafficFlowSettingsRow()
|
|
||||||
return `# Вставить в /opt/cdn-mm/docker-compose.yml под services.backend
|
|
||||||
# На хосте сначала: wg-quick up wg-flow (адрес ${row.collectorIp})
|
|
||||||
# затем: docker compose up -d backend
|
|
||||||
# Traefik не трогать. UDP ${row.flowListenPort} не публиковать на 0.0.0.0.
|
|
||||||
|
|
||||||
environment:
|
|
||||||
FLOW_LISTEN_HOST: "0.0.0.0"
|
|
||||||
ports:
|
|
||||||
- "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildHostNftSnippet(): string {
|
|
||||||
const row = getTrafficFlowSettingsRow()
|
|
||||||
return `# Firewall хоста Docker MM. WG — клиент к JH:13232 (исходящий).
|
|
||||||
# UDP ${row.flowListenPort} наружу НЕ открывать.
|
|
||||||
table inet filter {
|
|
||||||
chain input {
|
|
||||||
type filter hook input priority 0;
|
|
||||||
iifname "wg-flow" udp dport ${row.flowListenPort} accept
|
|
||||||
udp dport ${row.flowListenPort} drop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildHostUfwSnippet(): string {
|
|
||||||
const row = getTrafficFlowSettingsRow()
|
const row = getTrafficFlowSettingsRow()
|
||||||
return [
|
return [
|
||||||
`# WG клиент: входящий listen не нужен`,
|
"# Docker Compose merge для /opt/cdn-mm",
|
||||||
`ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`,
|
"# Не править docker-compose.yml. Traefik не трогать.",
|
||||||
|
"# Сначала: wg-quick up wg-flow (адрес " + row.collectorIp + ")",
|
||||||
|
"# затем: docker compose up -d backend",
|
||||||
|
"",
|
||||||
|
"services:",
|
||||||
|
" backend:",
|
||||||
|
" environment:",
|
||||||
|
" FLOW_LISTEN_HOST: \"0.0.0.0\"",
|
||||||
|
" ports:",
|
||||||
|
` - "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"`,
|
||||||
|
"",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildHostLinuxInstallSh(): string {
|
||||||
|
const row = getTrafficFlowSettingsRow()
|
||||||
|
const conf = buildHostWgQuickConf().replace(/\s+$/, "") + "\n"
|
||||||
|
const override = buildHostComposeOverride()
|
||||||
|
const collector = row.collectorIp
|
||||||
|
const flowPort = row.flowListenPort
|
||||||
|
|
||||||
|
return `#!/usr/bin/env bash
|
||||||
|
# WG-клиент на хосте /opt/cdn-mm → JH:13232, IPFIX в контейнер backend.
|
||||||
|
# Запуск: sudo bash install-wg-flow.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ \${EUID:-$(id -u)} -ne 0 ]]; then
|
||||||
|
echo "Запустите от root: sudo bash $0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
COLLECTOR_IP="${collector}"
|
||||||
|
FLOW_PORT="${flowPort}"
|
||||||
|
COMPOSE_DIR="${COMPOSE_DIR}"
|
||||||
|
|
||||||
|
if ! command -v wg >/dev/null 2>&1; then
|
||||||
|
apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -m 700 /etc/wireguard
|
||||||
|
cat > /etc/wireguard/wg-flow.conf <<'WGEOF'
|
||||||
|
${conf}WGEOF
|
||||||
|
chmod 600 /etc/wireguard/wg-flow.conf
|
||||||
|
|
||||||
|
systemctl enable --now wg-quick@wg-flow
|
||||||
|
echo "=== wg show wg-flow ==="
|
||||||
|
wg show wg-flow
|
||||||
|
echo "=== адрес (ожидаем \${COLLECTOR_IP}/24) ==="
|
||||||
|
ip -4 addr show dev wg-flow
|
||||||
|
|
||||||
|
if [[ ! -d "\$COMPOSE_DIR" ]]; then
|
||||||
|
echo "Нет \$COMPOSE_DIR — положите override.yml туда вручную (вкладка compose)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "\$COMPOSE_DIR/docker-compose.override.yml" <<'OVEOF'
|
||||||
|
${override}OVEOF
|
||||||
|
|
||||||
|
cd "\$COMPOSE_DIR"
|
||||||
|
docker compose up -d backend
|
||||||
|
|
||||||
|
echo "=== UDP \${FLOW_PORT} на хосте ==="
|
||||||
|
ss -ulnp | grep -E "\${FLOW_PORT}" || true
|
||||||
|
echo "=== PortBindings mmapp-backend ==="
|
||||||
|
docker inspect -f '{{json .HostConfig.PortBindings}}' mmapp-backend
|
||||||
|
echo "=== handshake (keepalive 25s к JH:13232) ==="
|
||||||
|
wg show wg-flow
|
||||||
|
|
||||||
|
# ufw: исходящий WG не открывать; 4739 на WAN не публиковать
|
||||||
|
if command -v ufw >/dev/null 2>&1; then
|
||||||
|
ufw deny "\${FLOW_PORT}/udp" comment 'ipfix-not-public' || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только на \${COLLECTOR_IP}, не на 0.0.0.0."
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
|
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
|
||||||
return [
|
return [
|
||||||
|
{ id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: buildHostLinuxInstallSh() },
|
||||||
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
{ 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: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() },
|
||||||
{ id: "nft", label: "nftables", filename: "wg-flow.nft", code: buildHostNftSnippet() },
|
|
||||||
{ id: "ufw", label: "ufw", filename: "wg-flow.ufw.sh", code: buildHostUfwSnippet() },
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export type WgParsedConfig = {
|
|||||||
|
|
||||||
export type WgExportIface = {
|
export type WgExportIface = {
|
||||||
name: string
|
name: string
|
||||||
listenPort: number
|
listenPort?: number
|
||||||
mtu: number
|
mtu: number
|
||||||
comment?: string
|
comment?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
@@ -258,7 +258,7 @@ export function generateNativeConf(iface: WgExportIface, opts?: { includePrivate
|
|||||||
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
|
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
|
||||||
}
|
}
|
||||||
if (iface.address) lines.push(`Address = ${iface.address}`)
|
if (iface.address) lines.push(`Address = ${iface.address}`)
|
||||||
lines.push(`ListenPort = ${iface.listenPort}`)
|
if (iface.listenPort) lines.push(`ListenPort = ${iface.listenPort}`)
|
||||||
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
|
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
|
||||||
lines.push(``)
|
lines.push(``)
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ export function generateMikrotikRsc(iface: WgExportIface): string {
|
|||||||
lines.push(``)
|
lines.push(``)
|
||||||
lines.push(`/interface wireguard add \\`)
|
lines.push(`/interface wireguard add \\`)
|
||||||
lines.push(` name=${iface.name} \\`)
|
lines.push(` name=${iface.name} \\`)
|
||||||
lines.push(` listen-port=${iface.listenPort} \\`)
|
lines.push(` listen-port=${iface.listenPort ?? 13231} \\`)
|
||||||
lines.push(` mtu=${iface.mtu} \\`)
|
lines.push(` mtu=${iface.mtu} \\`)
|
||||||
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
|
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
|
||||||
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
|
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ function highlightLine(line: string): string {
|
|||||||
return "text-foreground/90"
|
return "text-foreground/90"
|
||||||
}
|
}
|
||||||
|
|
||||||
function CodeBlock({ code }: { code: string }) {
|
function CodeBlock({ code, className }: { code: string; className?: string }) {
|
||||||
const lines = code.length ? code.split("\n") : [""]
|
const lines = code.length ? code.split("\n") : [""]
|
||||||
return (
|
return (
|
||||||
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all">
|
<pre className={cn("px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all", className)}>
|
||||||
{lines.map((line, i) => (
|
{lines.map((line, i) => (
|
||||||
<span key={i} className={cn("block", highlightLine(line))}>
|
<span key={i} className={cn("block", highlightLine(line))}>
|
||||||
{line || " "}
|
{line || " "}
|
||||||
@@ -281,4 +281,4 @@ function CodeExportSheet({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { CodeExportSheet, downloadText }
|
export { CodeExportSheet, CodeBlock, downloadText }
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ 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 { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { downloadText } from "@/components/reui-kit/code-export-sheet"
|
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
@@ -15,7 +23,7 @@ import {
|
|||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||||
import type { TrafficFlowHostFile, TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
@@ -45,13 +53,13 @@ function FlowOverlaySheet({
|
|||||||
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 [copied, setCopied] = useState(false)
|
||||||
const [tab, setTab] = useState("wg-quick")
|
const [tab, setTab] = useState("linux")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setResult(null)
|
setResult(null)
|
||||||
setCopied(false)
|
setCopied(false)
|
||||||
setTab("wg-quick")
|
setTab("linux")
|
||||||
const first = jumpHosts[0]
|
const first = jumpHosts[0]
|
||||||
const nextId = first ? String(first.id) : ""
|
const nextId = first ? String(first.id) : ""
|
||||||
setServerId(nextId)
|
setServerId(nextId)
|
||||||
@@ -64,20 +72,12 @@ function FlowOverlaySheet({
|
|||||||
if (selected) setEndpoint(selected.host)
|
if (selected) setEndpoint(selected.host)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formats = useMemo((): TrafficFlowHostFile[] => {
|
const formats = result?.hostFiles ?? []
|
||||||
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 active = formats.find((f) => f.id === tab) ?? formats[0]
|
||||||
|
const selectedHost = jumpHosts.find((s) => String(s.id) === serverId)
|
||||||
|
const selectedLabel = selectedHost
|
||||||
|
? `${selectedHost.name || selectedHost.host} (${selectedHost.host})`
|
||||||
|
: "Выберите сервер…"
|
||||||
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
|
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -86,7 +86,7 @@ function FlowOverlaySheet({
|
|||||||
try {
|
try {
|
||||||
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
|
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
|
||||||
setResult(res)
|
setResult(res)
|
||||||
setTab(res.hostFiles[0]?.id ?? "peer")
|
setTab(res.hostFiles[0]?.id ?? "linux")
|
||||||
toast.success(`wg-flow на ${res.address}`)
|
toast.success(`wg-flow на ${res.address}`)
|
||||||
onDone?.(res)
|
onDone?.(res)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -108,27 +108,35 @@ function FlowOverlaySheet({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col gap-0 p-0">
|
<SheetContent
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
side="right"
|
||||||
<SheetTitle>Подключить jump-host</SheetTitle>
|
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
|
||||||
|
<SheetTitle className="text-base font-semibold tracking-tight">
|
||||||
|
Подключить jump-host
|
||||||
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Создаст wg-flow на выбранном MikroTik (сервер, listen 13232) и сразу выдаст wg-quick / compose для Linux-хоста Docker MM (клиент).
|
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||||
<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"
|
value={serverId || undefined}
|
||||||
value={serverId}
|
onValueChange={(v) => handleServerChange(String(v ?? ""))}
|
||||||
onChange={(e) => handleServerChange(e.target.value)}
|
|
||||||
>
|
>
|
||||||
<option value="">Выберите сервер…</option>
|
<SelectTrigger className="h-9 w-full min-w-0">
|
||||||
|
<SelectValue>{selectedLabel}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="start" className="min-w-(--anchor-width)">
|
||||||
{jumpHosts.map((s) => (
|
{jumpHosts.map((s) => (
|
||||||
<option key={s.id} value={s.id}>
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
{s.name || s.host} ({s.host})
|
{s.name || s.host} ({s.host})
|
||||||
</option>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
label="Публичный IP или DNS jump-host"
|
label="Публичный IP или DNS jump-host"
|
||||||
@@ -144,21 +152,24 @@ function FlowOverlaySheet({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{result ? (
|
{result ? (
|
||||||
<div className="flex flex-col gap-4 min-h-0">
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
<Alert>
|
<Alert variant="success">
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
<AlertTitle>Ключи и UDP 4739</AlertTitle>
|
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Приватный ключ хоста в SQLite панели — не кладите в git. UDP 4739 публикуйте только на WG-IP хоста, не на 0.0.0.0 контейнера.
|
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
|
<ul className="flex flex-col gap-1.5">
|
||||||
{result.steps.map((s) => (
|
{result.steps.map((s) => (
|
||||||
<li key={s}>{s}</li>
|
<li key={s} className="flex items-start gap-2 text-sm">
|
||||||
|
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-success" />
|
||||||
|
<span>{s}</span>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{formats.length > 0 && active ? (
|
{formats.length > 0 && active ? (
|
||||||
<div className="flex flex-col gap-3 min-h-0">
|
<div className="flex min-h-0 flex-col gap-3">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
@@ -169,17 +180,17 @@ function FlowOverlaySheet({
|
|||||||
>
|
>
|
||||||
<TabsList className="h-9 w-full">
|
<TabsList className="h-9 w-full">
|
||||||
{formats.map((f) => (
|
{formats.map((f) => (
|
||||||
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-1.5 text-xs sm:text-sm">
|
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
|
||||||
{f.label}
|
{f.label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Frame dense className="flex min-h-0 flex-col">
|
<Frame dense className="flex min-h-0 flex-col overflow-hidden">
|
||||||
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
|
<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]">
|
<ScrollArea className="h-full min-h-0 max-h-[min(52vh,22rem)]">
|
||||||
{active.code}
|
<CodeBlock code={active.code} className="whitespace-pre break-normal" />
|
||||||
</pre>
|
</ScrollArea>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -202,8 +213,10 @@ function FlowOverlaySheet({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
|
||||||
<SheetClose render={<Button variant="outline" />}>Закрыть</SheetClose>
|
<SheetClose render={<Button type="button" variant="outline" className="shrink-0" />}>
|
||||||
|
Закрыть
|
||||||
|
</SheetClose>
|
||||||
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
|
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
|
||||||
{busy ? "Подключение…" : "Подключить"}
|
{busy ? "Подключение…" : "Подключить"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user