fix(ui): улучшить функциональность панели данных и заменить устаревшие компоненты
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 2m7s
Docker images / updater-image (push) Successful in 41s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 2m7s
Docker images / updater-image (push) Successful in 41s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
This commit is contained in:
@@ -9,6 +9,7 @@ import type { CertStatus, Server } from "@/lib/data"
|
|||||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -36,7 +37,6 @@ import {
|
|||||||
} from "@/shared/api/certificates"
|
} from "@/shared/api/certificates"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
SearchIcon,
|
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
BadgeCheckIcon,
|
BadgeCheckIcon,
|
||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
@@ -237,58 +237,6 @@ function CertPartAcmeSettings({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CertPartTableToolbar({
|
|
||||||
search,
|
|
||||||
setSearch,
|
|
||||||
statusFilter,
|
|
||||||
setStatusFilter,
|
|
||||||
filteredCount,
|
|
||||||
}: {
|
|
||||||
search: string
|
|
||||||
setSearch: (v: string) => void
|
|
||||||
statusFilter: CertStatus | "all"
|
|
||||||
setStatusFilter: (v: CertStatus | "all") => void
|
|
||||||
filteredCount: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
|
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
|
||||||
<input
|
|
||||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
|
||||||
placeholder="Поиск по имени, CN, эмитенту…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
|
||||||
{(["all", "valid", "expired", "revoked"] as const).map((s) => (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setStatusFilter(s)}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
|
||||||
statusFilter === s
|
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s === "all"
|
|
||||||
? "Все"
|
|
||||||
: s === "valid"
|
|
||||||
? "Действующие"
|
|
||||||
: s === "expired"
|
|
||||||
? "Истёкшие"
|
|
||||||
: "Отозванные"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filteredCount} сертификатов</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CertPartReference() {
|
function CertPartReference() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -747,12 +695,21 @@ export default function CertificatesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<CertPartTableToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
setSearch={setSearch}
|
onSearchChange={setSearch}
|
||||||
statusFilter={statusFilter}
|
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
||||||
setStatusFilter={setStatusFilter}
|
segmented={{
|
||||||
filteredCount={filtered.length}
|
value: statusFilter,
|
||||||
|
onChange: setStatusFilter,
|
||||||
|
options: [
|
||||||
|
{ value: "all", label: "Все" },
|
||||||
|
{ value: "valid", label: "Действующие" },
|
||||||
|
{ value: "expired", label: "Истёкшие" },
|
||||||
|
{ value: "revoked", label: "Отозванные" },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
countLabel={`${filtered.length} сертификатов`}
|
||||||
/>
|
/>
|
||||||
<CertificatesDataGrid
|
<CertificatesDataGrid
|
||||||
certificates={filtered}
|
certificates={filtered}
|
||||||
|
|||||||
@@ -1083,17 +1083,16 @@ export default function DataCollectionPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<DataPageCard>
|
||||||
<CardHeader className="border-b border-border pb-4">
|
<div className="border-b border-border px-5 py-4">
|
||||||
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
<p className="text-base font-medium">Планировщик сбора данных</p>
|
||||||
<CardDescription className="text-xs">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
|
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="px-0 pb-0">
|
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
<Separator />
|
||||||
<Separator />
|
<div className="space-y-3 px-5 py-4">
|
||||||
<div className="space-y-3 px-5 py-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||||
@@ -1202,8 +1201,7 @@ export default function DataCollectionPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</DataPageCard>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="border-b border-border flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
<CardHeader className="border-b border-border flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
|||||||
@@ -1805,7 +1805,7 @@ export default function FiltersPage() {
|
|||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
|
|
||||||
{/* selected server header */}
|
{/* selected server header */}
|
||||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
|
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||||
<StatusDot status={selectedServer.status} pulse={selectedServer.status === "online"} />
|
<StatusDot status={selectedServer.status} pulse={selectedServer.status === "online"} />
|
||||||
<Flag code={selectedServer.country} size={16} />
|
<Flag code={selectedServer.country} size={16} />
|
||||||
<span className="font-mono text-sm font-semibold">{selectedServer.name}</span>
|
<span className="font-mono text-sm font-semibold">{selectedServer.name}</span>
|
||||||
@@ -1880,7 +1880,7 @@ export default function FiltersPage() {
|
|||||||
|
|
||||||
{/* add rule shortcut */}
|
{/* add rule shortcut */}
|
||||||
<button onClick={openCreate}
|
<button onClick={openCreate}
|
||||||
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
Добавить правило для {selectedServer.name}
|
Добавить правило для {selectedServer.name}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,11 +9,17 @@ import {
|
|||||||
} from "@/components/data-grids/firewall-rules-data-grid"
|
} from "@/components/data-grids/firewall-rules-data-grid"
|
||||||
import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid"
|
import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
import { DataPageToolbarFrame } from "@/components/data-page-toolbar"
|
||||||
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { firewallRules, type FirewallRule } from "@/lib/data"
|
import { firewallRules, type FirewallRule } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
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 {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
@@ -1133,15 +1139,13 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
|
|
||||||
{/* Rules table */}
|
{/* Rules table */}
|
||||||
{rules.length > 0 ? (
|
{rules.length > 0 ? (
|
||||||
<div className="rounded-lg border overflow-hidden">
|
<FirewallScenarioRulesDataGrid
|
||||||
<FirewallScenarioRulesDataGrid
|
rules={rules}
|
||||||
rules={rules}
|
onToggleEnabled={toggleEnabled}
|
||||||
onToggleEnabled={toggleEnabled}
|
onMoveUp={(id) => moveRule(id, -1)}
|
||||||
onMoveUp={(id) => moveRule(id, -1)}
|
onMoveDown={(id) => moveRule(id, 1)}
|
||||||
onMoveDown={(id) => moveRule(id, 1)}
|
onRemove={removeRule}
|
||||||
onRemove={removeRule}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
!addOpen && (
|
!addOpen && (
|
||||||
<div className="text-center py-6 text-sm text-muted-foreground border rounded-lg border-dashed">
|
<div className="text-center py-6 text-sm text-muted-foreground border rounded-lg border-dashed">
|
||||||
@@ -1784,52 +1788,41 @@ export default function FirewallPage() {
|
|||||||
<SimulatorTab rules={rules} />
|
<SimulatorTab rules={rules} />
|
||||||
) : (
|
) : (
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
{/* toolbar */}
|
<DataPageToolbarFrame>
|
||||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
<SegmentedControl
|
||||||
{/* IP family selector */}
|
value={ipFamily}
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
onChange={setIpFamily}
|
||||||
{(["all", "ip", "ip6"] as IpFamily[]).map((f) => (
|
options={(["all", "ip", "ip6"] as IpFamily[]).map((f) => ({
|
||||||
<button key={f} onClick={() => setIpFamily(f)}
|
value: f,
|
||||||
className={cn(
|
label: IP_FAMILY_LABELS[f],
|
||||||
"flex items-center gap-1.5 rounded px-3 py-1 text-xs transition-colors whitespace-nowrap",
|
}))}
|
||||||
ipFamily === f
|
/>
|
||||||
? "bg-background text-foreground shadow-sm"
|
<SegmentedControl
|
||||||
: "text-muted-foreground hover:text-foreground",
|
value={chainFilter}
|
||||||
)}>
|
onChange={setChainFilter}
|
||||||
{IP_FAMILY_LABELS[f]}
|
options={[
|
||||||
</button>
|
{ value: "all", label: "Все", count: chainCounts.all ?? 0 },
|
||||||
))}
|
...chainsInGroup.map((c) => ({
|
||||||
</div>
|
value: c,
|
||||||
|
label: c,
|
||||||
{/* chain sub-tabs */}
|
count: chainCounts[c] ?? 0,
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
})),
|
||||||
{[{ value: "all", label: "Все" }, ...chainsInGroup.map((c) => ({ value: c, label: c }))].map((t) => (
|
]}
|
||||||
<button key={t.value} onClick={() => setChainFilter(t.value)}
|
/>
|
||||||
className={cn(
|
<InputGroup className="min-w-[220px] max-w-sm">
|
||||||
"flex items-center gap-1.5 rounded px-3 py-1 text-xs transition-colors",
|
<InputGroupAddon>
|
||||||
chainFilter === t.value
|
<SearchIcon className="size-3.5" />
|
||||||
? "bg-background text-foreground shadow-sm"
|
</InputGroupAddon>
|
||||||
: "text-muted-foreground hover:text-foreground",
|
<InputGroupInput
|
||||||
)}>
|
|
||||||
{t.label}
|
|
||||||
<span className="opacity-50">{chainCounts[t.value] ?? 0}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* search */}
|
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
|
||||||
<input
|
|
||||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
|
||||||
placeholder="Поиск по адресу, действию…"
|
placeholder="Поиск по адресу, действию…"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</InputGroup>
|
||||||
|
<span className="text-sm text-muted-foreground ml-auto">
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filteredRules.length} правил</span>
|
{filteredRules.length} правил
|
||||||
</div>
|
</span>
|
||||||
|
</DataPageToolbarFrame>
|
||||||
|
|
||||||
<FirewallRulesDataGrid rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
|
<FirewallRulesDataGrid rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|||||||
+21
-21
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar, DataPageToolbarFrame } from "@/components/data-page-toolbar"
|
||||||
import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid"
|
import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid"
|
||||||
import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
|
import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
|
||||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
@@ -25,7 +26,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import {
|
import {
|
||||||
PlusIcon, SearchIcon, RefreshCwIcon, MoreHorizontalIcon,
|
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||||
@@ -454,23 +455,21 @@ export default function GrePage() {
|
|||||||
{/* ── Tunnels ── */}
|
{/* ── Tunnels ── */}
|
||||||
{pageTab === "tunnels" && (
|
{pageTab === "tunnels" && (
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
segmented={{
|
||||||
{tunnelTabs.map((t) => (
|
value: tabFilter,
|
||||||
<button key={t.value} onClick={() => setTabFilter(t.value)}
|
onChange: setTabFilter,
|
||||||
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${tabFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
options: tunnelTabs.map((t) => ({
|
||||||
{t.label}
|
value: t.value,
|
||||||
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
|
label: t.label,
|
||||||
</button>
|
count: t.count,
|
||||||
))}
|
})),
|
||||||
</div>
|
}}
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
search={search}
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
onSearchChange={setSearch}
|
||||||
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
searchPlaceholder="Поиск по имени, IP…"
|
||||||
placeholder="Поиск по имени, IP…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
countLabel={`${filtered.length} туннелей`}
|
||||||
</div>
|
/>
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GreTunnelsDataGrid
|
<GreTunnelsDataGrid
|
||||||
tunnels={filtered}
|
tunnels={filtered}
|
||||||
@@ -484,12 +483,13 @@ export default function GrePage() {
|
|||||||
{/* ── IP Pools ── */}
|
{/* ── IP Pools ── */}
|
||||||
{pageTab === "pools" && (
|
{pageTab === "pools" && (
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
<DataPageToolbarFrame className="justify-between">
|
||||||
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
||||||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||||||
<PlusIcon className="size-4" />Добавить пул
|
<PlusIcon className="size-4" />
|
||||||
|
Добавить пул
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DataPageToolbarFrame>
|
||||||
<GrePoolsDataGrid pools={displayPools} />
|
<GrePoolsDataGrid pools={displayPools} />
|
||||||
|
|
||||||
<div className="border-t px-5 py-4">
|
<div className="border-t px-5 py-4">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
inferCountry,
|
inferCountry,
|
||||||
} from "@/components/data-grids/recursive-routes-data-grid"
|
} from "@/components/data-grids/recursive-routes-data-grid"
|
||||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -654,9 +655,9 @@ export default function RecursiveRoutesPage() {
|
|||||||
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="overflow-hidden py-0 gap-0">
|
<DataPageCard>
|
||||||
{currentServer && (
|
{currentServer && (
|
||||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
|
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||||
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
|
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
|
||||||
<Flag code={currentServer.country} size={16} />
|
<Flag code={currentServer.country} size={16} />
|
||||||
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
|
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
|
||||||
@@ -683,11 +684,11 @@ export default function RecursiveRoutesPage() {
|
|||||||
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
|
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
|
||||||
/>
|
/>
|
||||||
<button onClick={openCreate}
|
<button onClick={openCreate}
|
||||||
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
Добавить маршрут
|
Добавить маршрут
|
||||||
</button>
|
</button>
|
||||||
</Card>
|
</DataPageCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+22
-46
@@ -11,6 +11,7 @@ import { Flag } from "@/components/flag"
|
|||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { Sparkline } from "@/components/sparkline"
|
import { Sparkline } from "@/components/sparkline"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import {
|
import {
|
||||||
UptimeResourcesDataGrid,
|
UptimeResourcesDataGrid,
|
||||||
type UptimeResourceRow,
|
type UptimeResourceRow,
|
||||||
@@ -981,48 +982,25 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
{/* Type filter */}
|
|
||||||
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5 shrink-0">
|
|
||||||
{typeOpts.map(o => (
|
|
||||||
<button key={o.value} onClick={() => setTypeFilter(o.value)}
|
|
||||||
className={cn(
|
|
||||||
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
|
|
||||||
typeFilter === o.value
|
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative min-w-[180px] flex-1 max-w-xs">
|
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
|
||||||
<Input className="pl-8 h-8 text-sm" placeholder="Поиск по имени, площадке…"
|
|
||||||
value={resSearch} onChange={e => setResSearch(e.target.value)} />
|
|
||||||
{resSearch && (
|
|
||||||
<button onClick={() => setResSearch("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
|
||||||
<XIcon className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
|
||||||
{visible.length} из {rows.length} серверов
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Export CSV */}
|
|
||||||
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
|
|
||||||
<DownloadIcon className="size-3.5" />CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
segmented={{
|
||||||
|
value: typeFilter,
|
||||||
|
onChange: setTypeFilter,
|
||||||
|
options: typeOpts,
|
||||||
|
}}
|
||||||
|
search={resSearch}
|
||||||
|
onSearchChange={setResSearch}
|
||||||
|
searchPlaceholder="Поиск по имени, площадке…"
|
||||||
|
countLabel={`${visible.length} из ${rows.length} серверов`}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
|
||||||
|
<DownloadIcon className="size-3.5" />
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<UptimeResourcesDataGrid rows={visible} />
|
<UptimeResourcesDataGrid rows={visible} />
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
@@ -2273,15 +2251,13 @@ export default function UptimePage() {
|
|||||||
|
|
||||||
{/* ── history ── */}
|
{/* ── history ── */}
|
||||||
{speedRuns.length > 0 && (
|
{speedRuns.length > 0 && (
|
||||||
<Card className="overflow-hidden">
|
<DataPageCard>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||||
<p className="text-sm font-medium">История тестов</p>
|
<p className="text-sm font-medium">История тестов</p>
|
||||||
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
|
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<UptimeSpeedHistoryDataGrid runs={speedRuns} servers={allServers} />
|
||||||
<UptimeSpeedHistoryDataGrid runs={speedRuns} servers={allServers} />
|
</DataPageCard>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ interface CompactDataGridProps<T extends { id: string }> {
|
|||||||
emptyDescription?: string
|
emptyDescription?: string
|
||||||
onRowClick?: (row: T) => void
|
onRowClick?: (row: T) => void
|
||||||
getExpandedContent?: (row: T) => ReactNode
|
getExpandedContent?: (row: T) => ReactNode
|
||||||
compact?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactDataGrid<T extends { id: string }>({
|
function CompactDataGrid<T extends { id: string }>({
|
||||||
@@ -47,12 +46,7 @@ function CompactDataGrid<T extends { id: string }>({
|
|||||||
emptyDescription,
|
emptyDescription,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
getExpandedContent,
|
getExpandedContent,
|
||||||
compact = false,
|
|
||||||
}: CompactDataGridProps<T>) {
|
}: CompactDataGridProps<T>) {
|
||||||
const pad = compact ? "py-2" : DATA_GRID_CELL_PAD
|
|
||||||
const padFirst = compact ? "pl-4 py-2" : DATA_GRID_CELL_PAD_FIRST
|
|
||||||
const padLast = compact ? "pr-4 py-2" : DATA_GRID_CELL_PAD_LAST
|
|
||||||
|
|
||||||
const columnDefs = useMemo<ColumnDef<T>[]>(
|
const columnDefs = useMemo<ColumnDef<T>[]>(
|
||||||
() =>
|
() =>
|
||||||
columns.map((col, index) => ({
|
columns.map((col, index) => ({
|
||||||
@@ -67,16 +61,24 @@ function CompactDataGrid<T extends { id: string }>({
|
|||||||
headerTitle: col.header,
|
headerTitle: col.header,
|
||||||
headerClassName:
|
headerClassName:
|
||||||
col.headerClassName ??
|
col.headerClassName ??
|
||||||
(index === 0 ? padFirst : index === columns.length - 1 ? padLast : pad),
|
(index === 0
|
||||||
|
? DATA_GRID_CELL_PAD_FIRST
|
||||||
|
: index === columns.length - 1
|
||||||
|
? DATA_GRID_CELL_PAD_LAST
|
||||||
|
: DATA_GRID_CELL_PAD),
|
||||||
cellClassName:
|
cellClassName:
|
||||||
col.cellClassName ??
|
col.cellClassName ??
|
||||||
(index === 0 ? padFirst : index === columns.length - 1 ? padLast : pad),
|
(index === 0
|
||||||
|
? DATA_GRID_CELL_PAD_FIRST
|
||||||
|
: index === columns.length - 1
|
||||||
|
? DATA_GRID_CELL_PAD_LAST
|
||||||
|
: DATA_GRID_CELL_PAD),
|
||||||
...(getExpandedContent && index === 0
|
...(getExpandedContent && index === 0
|
||||||
? { expandedContent: getExpandedContent }
|
? { expandedContent: getExpandedContent }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
[columns, getExpandedContent, pad, padFirst, padLast],
|
[columns, getExpandedContent],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -180,7 +180,6 @@ function DashboardActiveProbesDataGrid({
|
|||||||
<CompactDataGrid
|
<CompactDataGrid
|
||||||
data={probes}
|
data={probes}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
compact
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyTitle={emptyTitle}
|
emptyTitle={emptyTitle}
|
||||||
emptyDescription={emptyDescription}
|
emptyDescription={emptyDescription}
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ function SettingsAccessSummaryDataGrid({
|
|||||||
<CompactDataGrid
|
<CompactDataGrid
|
||||||
data={users}
|
data={users}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
compact
|
|
||||||
emptyTitle="Нет пользователей"
|
emptyTitle="Нет пользователей"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ const DEFAULT_DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<object>["tableCla
|
|||||||
bodyRow: "group/row",
|
bodyRow: "group/row",
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_GRID_CONTAINER_CLASS = "rounded-none border-0"
|
const DATA_GRID_CONTAINER_CLASS = "w-full rounded-none border-0"
|
||||||
|
|
||||||
|
const DATA_PAGE_CARD_CLASS = "flex w-full flex-col overflow-hidden py-0 gap-0"
|
||||||
|
|
||||||
|
const DATA_PAGE_TOOLBAR_CLASS = "flex items-center gap-3 px-5 py-3 border-b flex-wrap"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DATA_GRID_CELL_PAD,
|
DATA_GRID_CELL_PAD,
|
||||||
@@ -25,4 +29,6 @@ export {
|
|||||||
DEFAULT_DATA_GRID_LAYOUT,
|
DEFAULT_DATA_GRID_LAYOUT,
|
||||||
DEFAULT_DATA_GRID_CLASS_NAMES,
|
DEFAULT_DATA_GRID_CLASS_NAMES,
|
||||||
DATA_GRID_CONTAINER_CLASS,
|
DATA_GRID_CONTAINER_CLASS,
|
||||||
|
DATA_PAGE_CARD_CLASS,
|
||||||
|
DATA_PAGE_TOOLBAR_CLASS,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function DataGridShell<TData extends object>({
|
|||||||
}: DataGridShellProps<TData>) {
|
}: DataGridShellProps<TData>) {
|
||||||
return (
|
return (
|
||||||
<DataGrid
|
<DataGrid
|
||||||
|
className="w-full"
|
||||||
table={table}
|
table={table}
|
||||||
recordCount={recordCount}
|
recordCount={recordCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ function SnapshotErrorCell({ error, className }: { error?: string; className?: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SnapshotDataGrid<T extends SnapshotRow>(
|
function SnapshotDataGrid<T extends SnapshotRow>(
|
||||||
props: Omit<CompactDataGridProps<T>, "compact">,
|
props: CompactDataGridProps<T>,
|
||||||
) {
|
) {
|
||||||
return <CompactDataGrid {...props} compact />
|
return <CompactDataGrid {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrafficSnapshotGrid({ servers }: { servers: TrafficServerSnapshot[] }) {
|
function TrafficSnapshotGrid({ servers }: { servers: TrafficServerSnapshot[] }) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/data-grid-layout"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface DataPageCardProps {
|
interface DataPageCardProps {
|
||||||
@@ -9,7 +10,7 @@ interface DataPageCardProps {
|
|||||||
|
|
||||||
function DataPageCard({ children, className }: DataPageCardProps) {
|
function DataPageCard({ children, className }: DataPageCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn("overflow-hidden py-0 gap-0", className)}>
|
<Card className={cn(DATA_PAGE_CARD_CLASS, className)}>
|
||||||
{children}
|
{children}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,21 @@ import {
|
|||||||
type FilterFieldConfig,
|
type FilterFieldConfig,
|
||||||
} from "@/components/reui/filters"
|
} from "@/components/reui/filters"
|
||||||
import { SegmentedControl } from "@/components/form-kit"
|
import { SegmentedControl } from "@/components/form-kit"
|
||||||
|
import { DATA_PAGE_TOOLBAR_CLASS } from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
|
||||||
|
function DataPageToolbarFrame({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn(DATA_PAGE_TOOLBAR_CLASS, className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface DataPageToolbarProps<T extends string = string> {
|
interface DataPageToolbarProps<T extends string = string> {
|
||||||
search?: string
|
search?: string
|
||||||
@@ -45,7 +60,7 @@ function DataPageToolbar<T extends string = string>({
|
|||||||
className,
|
className,
|
||||||
}: DataPageToolbarProps<T>) {
|
}: DataPageToolbarProps<T>) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-3 px-5 py-3 border-b flex-wrap", className)}>
|
<DataPageToolbarFrame className={className}>
|
||||||
{segmented && (
|
{segmented && (
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={segmented.value}
|
value={segmented.value}
|
||||||
@@ -77,8 +92,8 @@ function DataPageToolbar<T extends string = string>({
|
|||||||
<span className="text-sm text-muted-foreground ml-auto">{countLabel}</span>
|
<span className="text-sm text-muted-foreground ml-auto">{countLabel}</span>
|
||||||
)}
|
)}
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</DataPageToolbarFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { DataPageToolbar, type DataPageToolbarProps }
|
export { DataPageToolbar, DataPageToolbarFrame, type DataPageToolbarProps }
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user