feat(ui): integrate KpiStatGrid for enhanced statistics display
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
This commit is contained in:
@@ -7,12 +7,13 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||
import type { CertStatus, Server } from "@/lib/data"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@@ -119,42 +120,41 @@ function CertPartKpi({
|
||||
expired: CertificateDto[]
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка сертификатов"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: displayCerts.length,
|
||||
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />,
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "valid",
|
||||
label: "Действующих",
|
||||
value: displayCerts.filter((c) => c.status === "valid").length,
|
||||
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
|
||||
icon: <BadgeCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "expiring",
|
||||
label: "Истекают",
|
||||
value: expiring.length,
|
||||
icon: <AlertTriangleIcon className="size-4 text-amber-500" />,
|
||||
icon: <AlertTriangleIcon className="size-4" />,
|
||||
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: expiring.length > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "expired",
|
||||
label: "Истёкших",
|
||||
value: expired.length,
|
||||
icon: <AlertCircleIcon className="size-4 text-red-500" />,
|
||||
icon: <AlertCircleIcon className="size-4" />,
|
||||
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
||||
variant: expired.length > 0 ? "destructive" : "default",
|
||||
},
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{s.icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -437,6 +437,8 @@ export default function CertificatesPage() {
|
||||
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
||||
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
|
||||
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
||||
"https://acme-v02.api.letsencrypt.org/directory",
|
||||
)
|
||||
@@ -451,6 +453,27 @@ export default function CertificatesPage() {
|
||||
return routerCertificates.map(mockToDto)
|
||||
}, [prefsHydrated, isLive, certificates])
|
||||
|
||||
const displayServers = isLive ? serverList : mockServers
|
||||
|
||||
const scopedCerts = useMemo(() => {
|
||||
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
||||
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
||||
}, [displayCerts, selectedServerId])
|
||||
|
||||
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
|
||||
}))
|
||||
), [displayServers, displayCerts])
|
||||
|
||||
const serverById = useMemo(() => {
|
||||
const map = new Map<string, Server>()
|
||||
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
|
||||
@@ -515,13 +538,13 @@ export default function CertificatesPage() {
|
||||
}, [isLive, loadLive, loadAcmeSettings])
|
||||
|
||||
const expiring = useMemo(
|
||||
() => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
[displayCerts],
|
||||
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
[scopedCerts],
|
||||
)
|
||||
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts])
|
||||
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return displayCerts.filter((c) => {
|
||||
return scopedCerts.filter((c) => {
|
||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
@@ -532,7 +555,7 @@ export default function CertificatesPage() {
|
||||
c.sans.some((s) => s.includes(q))
|
||||
)
|
||||
})
|
||||
}, [displayCerts, search, statusFilter])
|
||||
}, [scopedCerts, search, statusFilter])
|
||||
|
||||
async function handleRefresh() {
|
||||
if (!liveReady) return
|
||||
@@ -625,35 +648,52 @@ export default function CertificatesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!liveReady || loadState === "loading"}
|
||||
onClick={() => {
|
||||
void handleRefresh()
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
|
||||
<PlusIcon className="size-4" />
|
||||
Выпустить сертификат
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={certRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!liveReady || loadState === "loading"}
|
||||
onClick={() => {
|
||||
void handleRefresh()
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!liveReady || issueBusy}
|
||||
onClick={() => {
|
||||
setIssueStep(1)
|
||||
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||
setIssueOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Выпустить сертификат
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{isLive && backendStatus === false && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||
@@ -673,7 +713,7 @@ export default function CertificatesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} />
|
||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||
|
||||
{liveReady && (
|
||||
<CertPartAcmeSettings
|
||||
@@ -720,7 +760,7 @@ export default function CertificatesPage() {
|
||||
|
||||
<CertPartReference />
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||
@@ -753,7 +793,7 @@ export default function CertificatesPage() {
|
||||
<StepperContent key={s} value={s}>
|
||||
<CertPartIssueForm
|
||||
step={s as 1 | 2 | 3 | 4}
|
||||
serverList={serverList}
|
||||
serverList={displayServers}
|
||||
issueServerId={issueServerId}
|
||||
setIssueServerId={setIssueServerId}
|
||||
issueCertName={issueCertName}
|
||||
@@ -812,6 +852,6 @@ export default function CertificatesPage() {
|
||||
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user