fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s

This commit is contained in:
Denozordec
2026-06-30 22:22:51 +07:00
parent a1a9124f3d
commit d3a2d38b37
63 changed files with 8509 additions and 3652 deletions
+18 -167
View File
@@ -2,6 +2,13 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header"
import {
FirewallRulesDataGrid,
ActionBadge,
ChainBadge,
} from "@/components/data-grids/firewall-rules-data-grid"
import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { firewallRules, type FirewallRule } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
@@ -342,23 +349,7 @@ function fmtHits(n: number): string {
return String(n)
}
function ActionBadge({ action }: { action: string }) {
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
return (
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
{action}
</span>
)
}
function ChainBadge({ chain }: { chain: string }) {
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
return (
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
{chain}
</span>
)
}
// ActionBadge, ChainBadge — из firewall-rules-data-grid
function NativeSelect({ value, onChange, children, className }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
@@ -1143,55 +1134,13 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
{/* Rules table */}
{rules.length > 0 ? (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/30 text-muted-foreground">
<th className="text-left px-3 py-2 w-6">#</th>
<th className="text-left px-3 py-2">Цепочка</th>
<th className="text-left px-3 py-2">Действие</th>
<th className="text-left px-3 py-2">Src</th>
<th className="text-left px-3 py-2">Dst</th>
<th className="text-left px-3 py-2">Порт</th>
<th className="text-left px-3 py-2">Iface</th>
<th className="text-left px-3 py-2">Комментарий</th>
<th className="w-24 px-2 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{rules.map((r, i) => (
<tr key={r.id} className={cn(
"hover:bg-muted/20 transition-colors",
!r.enabled && "opacity-40",
)}>
<td className="px-3 py-1.5 text-muted-foreground tabular-nums">{i + 1}</td>
<td className="px-3 py-1.5"><ChainBadge chain={r.chain} /></td>
<td className="px-3 py-1.5"><ActionBadge action={r.action} /></td>
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.src || "any"}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.dst || "any"}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.port || "—"}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.iface || "—"}</td>
<td className="px-3 py-1.5 text-muted-foreground/70 max-w-[110px] truncate">{r.comment || "—"}</td>
<td className="px-2 py-1.5">
<div className="flex items-center gap-0.5 justify-end">
<button type="button" onClick={() => toggleEnabled(r.id)}
title={r.enabled ? "Отключить" : "Включить"}
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors">
<PowerIcon className="size-3.5" />
</button>
<button type="button" onClick={() => moveRule(r.id, -1)} disabled={i === 0}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"></button>
<button type="button" onClick={() => moveRule(r.id, 1)} disabled={i === rules.length - 1}
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors"></button>
<button type="button" onClick={() => removeRule(r.id)}
className="p-0.5 ml-0.5 text-muted-foreground/40 hover:text-red-500 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<FirewallScenarioRulesDataGrid
rules={rules}
onToggleEnabled={toggleEnabled}
onMoveUp={(id) => moveRule(id, -1)}
onMoveDown={(id) => moveRule(id, 1)}
onRemove={removeRule}
/>
</div>
) : (
!addOpen && (
@@ -1683,104 +1632,6 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
)
}
// ─── Rules Table ──────────────────────────────────────────────────────────────
function RulesTable({ rules, onToggle, onEdit }: {
rules: FirewallRule[]
onToggle: (id: string) => void
onEdit: (r: FirewallRule) => void
}) {
if (rules.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldOffIcon className="size-10 mb-3 opacity-30" />
<p className="text-sm font-medium">Правила не найдены</p>
<p className="text-xs mt-1">Попробуйте изменить фильтр или добавьте новое правило</p>
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3 w-8">#</th>
<th className="text-left font-medium px-4 py-3">Цепочка</th>
<th className="text-left font-medium px-4 py-3">Действие</th>
<th className="text-left font-medium px-4 py-3">Источник</th>
<th className="text-left font-medium px-4 py-3">Назначение</th>
<th className="text-left font-medium px-4 py-3">Протокол</th>
<th className="text-left font-medium px-4 py-3">Порт</th>
<th className="text-left font-medium px-4 py-3">Интерфейс</th>
<th className="text-right font-medium px-4 py-3">Пакетов</th>
<th className="text-left font-medium px-4 py-3 w-12">Вкл</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{rules.map((r, i) => (
<tr key={r.id}
className={cn("hover:bg-muted/40 transition-colors", !r.enabled && "opacity-40")}>
<td className="px-5 py-2.5 font-mono text-xs text-muted-foreground">{i + 1}</td>
<td className="px-4 py-2.5"><ChainBadge chain={r.chain} /></td>
<td className="px-4 py-2.5"><ActionBadge action={r.action} /></td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
{r.src || "any"}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
{r.dst || "any"}
</td>
<td className="px-4 py-2.5 text-xs font-mono">{r.proto}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.port || "—"}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.iface || "—"}</td>
<td className="px-4 py-2.5 text-right">
<span className={cn(
"text-xs font-mono tabular-nums",
r.hits > 1_000_000 ? "text-emerald-600 dark:text-emerald-400 font-semibold"
: r.hits > 10_000 ? "text-foreground"
: "text-muted-foreground",
)}>
{fmtHits(r.hits)}
</span>
</td>
<td className="px-4 py-2.5">
<FormToggle checked={r.enabled} onChange={() => onToggle(r.id)} />
</td>
<td className="px-3 py-2.5">
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onEdit(r)}>
<PencilIcon className="size-4" />Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<CopyIcon className="size-4" />Дублировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onToggle(r.id)}>
<PowerIcon className="size-4" />
{r.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />Удалить правило
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function FirewallPage() {
const [rules, setRules] = useState<FirewallRule[]>(firewallRules)
@@ -1932,7 +1783,7 @@ export default function FirewallPage() {
) : chainGroup === "simulator" ? (
<SimulatorTab rules={rules} />
) : (
<Card>
<DataPageCard>
{/* toolbar */}
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
{/* IP family selector */}
@@ -1980,8 +1831,8 @@ export default function FirewallPage() {
<span className="text-sm text-muted-foreground ml-auto">{filteredRules.length} правил</span>
</div>
<RulesTable rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
</Card>
<FirewallRulesDataGrid rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
</DataPageCard>
)}
{/* RouterOS reference */}