feat(censorcheck): добавить статус блокировок и launcher curl | bash
Docker / build (push) Failing after 25s

Прогон с VPS через HMAC-токен матчится к существующим серверам; UI /blocking показывает текущие проверки и историю.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-22 10:52:22 +07:00
co-authored by Cursor
parent 9e0311b53a
commit 5c43d88f1a
52 changed files with 3847 additions and 53 deletions
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import type { Filter } from '@/components/reui/filters'
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters'
import type { CensorcheckRunDto } from './types'
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
id: 'ccrun-1',
spaceId: 'space-main',
runId: '11111111-1111-4111-8111-111111111111',
probePublicIp: '203.0.113.10',
claimedPublicIp: null,
matchedVpsId: 'vps-1',
status: 'complete',
schemaVersion: 1,
launcherVersion: '1',
censorcheckVersion: '1',
summary: {
total: 2,
available: 1,
redirected: 0,
denied: 0,
blocked: 1,
timeout: 0,
error: 0,
},
createdAt: '2026-08-22T00:00:00.000Z',
completedAt: '2026-08-22T00:00:00.000Z',
observedSourceIp: '203.0.113.10',
vps: {
id: 'vps-1',
ip: '203.0.113.10',
dns: 'edge.example.com',
providerId: 'p1',
providerName: 'Hoster',
country: 'Нидерланды',
city: 'Amsterdam',
datacenter: 'AMS',
vcpu: 2,
ramGb: 4,
diskGb: 40,
},
results: [
{
id: 'r1',
runId: 'ccrun-1',
serviceKey: 'youtube.com',
serviceLabel: 'youtube.com',
category: 'dpi',
status: 'blocked',
httpStatus: -1,
detail: null,
},
{
id: 'r2',
runId: 'ccrun-1',
serviceKey: 'netflix.com',
serviceLabel: 'netflix.com',
category: 'geoblock',
status: 'available',
httpStatus: 200,
detail: null,
},
],
...overrides,
})
describe('filterCensorcheckRuns', () => {
it('фильтрует по статусу сервиса', () => {
const filters: Filter[] = [
{ id: '1', field: 'status', operator: 'is_any_of', values: ['blocked'] },
]
expect(filterCensorcheckRuns([run()], filters)).toHaveLength(1)
expect(
filterCensorcheckRuns([run()], [
{ id: '1', field: 'status', operator: 'is_any_of', values: ['timeout'] },
]),
).toHaveLength(0)
})
it('ищет по IP и DNS', () => {
const filters: Filter[] = [
{ id: '1', field: 'q', operator: 'contains', values: ['edge.example'] },
]
expect(filterCensorcheckRuns([run()], filters)).toHaveLength(1)
expect(
filterCensorcheckRuns([run()], [
{ id: '1', field: 'q', operator: 'contains', values: ['missing'] },
]),
).toHaveLength(0)
})
})
describe('groupRunsByService', () => {
it('собирает пробы по сервису', () => {
const groups = groupRunsByService([run()])
expect(groups.map((g) => g.serviceKey)).toEqual(['netflix.com', 'youtube.com'])
expect(groups[1]?.probes[0]?.status).toBe('blocked')
})
})
@@ -0,0 +1,105 @@
import { getActiveFilters } from '@/components/reui-kit'
import type { Filter } from '@/components/reui/filters'
import { runSearchText, type CensorcheckRunDto } from './types'
export function filterCensorcheckRuns(
runs: CensorcheckRunDto[],
filters: Filter[],
): CensorcheckRunDto[] {
const active = getActiveFilters(filters)
if (active.length === 0) return runs
return runs.filter((run) => {
for (const filter of active) {
const values = filter.values.map((value) => String(value))
if (filter.field === 'status') {
const statuses = (run.results ?? []).map((row) => row.status)
const hit = values.some((value) => statuses.includes(value))
if (filter.operator === 'is_not_any_of' ? hit : !hit) return false
continue
}
if (filter.field === 'service') {
const hay = (run.results ?? [])
.map((row) => `${row.serviceKey} ${row.serviceLabel}`)
.join(' ')
.toLowerCase()
const hit = values.some((value) =>
hay.includes(value.toLowerCase()) || (run.results ?? []).some((row) => row.serviceKey === value),
)
if (!hit) return false
continue
}
if (filter.field === 'hoster') {
const name = (run.vps?.providerName ?? '').toLowerCase()
const hit = values.some((value) => name.includes(value.toLowerCase()) || name === value.toLowerCase())
if (!hit) return false
continue
}
if (filter.field === 'country') {
const country = (run.vps?.country ?? '').toLowerCase()
const hit = values.some((value) => country.includes(value.toLowerCase()) || country === value.toLowerCase())
if (!hit) return false
continue
}
if (filter.field === 'matched') {
const matched = run.matchedVpsId ? 'matched' : 'unmatched'
if (!values.includes(matched)) return false
continue
}
if (filter.field === 'q') {
const hay = runSearchText(run)
const hit = values.some((token) => hay.includes(token.toLowerCase()))
if (!hit) return false
}
}
return true
})
}
export type BlockingServiceRow = {
id: string
serviceKey: string
serviceLabel: string
category: string
probes: Array<{
runId: string
probePublicIp: string
matchedVpsId: string | null
dns: string
country: string
status: string
createdAt: string
vpsId: string | null
}>
}
export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRow[] {
const map = new Map<string, BlockingServiceRow>()
for (const run of runs) {
for (const result of run.results ?? []) {
const existing = map.get(result.serviceKey)
const probe = {
runId: run.id,
probePublicIp: run.probePublicIp,
matchedVpsId: run.matchedVpsId,
dns: run.vps?.dns ?? '',
country: run.vps?.country ?? '',
status: result.status,
createdAt: run.createdAt,
vpsId: run.matchedVpsId,
}
if (existing) {
existing.probes.push(probe)
} else {
map.set(result.serviceKey, {
id: result.serviceKey,
serviceKey: result.serviceKey,
serviceLabel: result.serviceLabel,
category: result.category,
probes: [probe],
})
}
}
}
return [...map.values()].sort((a, b) => a.serviceKey.localeCompare(b.serviceKey))
}
@@ -0,0 +1,217 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
import { CountryFlag } from '@/components/country-flag'
import { StatusBadge } from '@/components/status-badge'
import { columnDefFromDataGrid, ExpandableResourceGrid } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import {
CENSORCHECK_STATUS_LABELS,
formatCheckedAt,
formatVpsResources,
type CensorcheckRunDto,
} from './types'
import type { BlockingServiceRow } from './blocking-filters'
function SummaryBadges({ run }: { run: CensorcheckRunDto }) {
const { summary } = run
return (
<div className="flex flex-wrap items-center gap-1">
{summary.available > 0 ? (
<Badge variant="success" size="sm">{summary.available} ок</Badge>
) : null}
{summary.blocked > 0 ? (
<Badge variant="destructive" size="sm">{summary.blocked} блок</Badge>
) : null}
{summary.denied > 0 ? (
<Badge variant="destructive" size="sm">{summary.denied} отказ</Badge>
) : null}
{summary.timeout > 0 ? (
<Badge variant="warning" size="sm">{summary.timeout} timeout</Badge>
) : null}
{summary.error > 0 ? (
<Badge variant="outline" size="sm">{summary.error} err</Badge>
) : null}
</div>
)
}
function NestedList({
rows,
}: {
rows: Array<{ key: string; primary: string; secondary?: string; status: string }>
}) {
return (
<div className="bg-muted/30 flex flex-col gap-1 px-4 py-3">
{rows.map((row) => (
<div key={row.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{row.primary}</span>
{row.secondary ? (
<span className="text-muted-foreground truncate text-xs">{row.secondary}</span>
) : null}
</div>
<StatusBadge
status={row.status}
label={CENSORCHECK_STATUS_LABELS[row.status] ?? row.status}
/>
</div>
))}
</div>
)
}
const vpsColumns: DataGridColumn<CensorcheckRunDto>[] = [
{
key: 'vps',
header: 'VPS / IP',
icon: ServerIcon,
sortValue: (row) => row.vps?.dns || row.probePublicIp,
cell: (row) => {
const title = row.vps?.dns || row.probePublicIp
const ip = row.probePublicIp
const link = row.matchedVpsId ? (
<Link
to="/vps/$vpsId"
params={{ vpsId: row.matchedVpsId }}
className="hover:text-primary font-medium"
onClick={(event) => event.stopPropagation()}
>
{title}
</Link>
) : (
<span className="font-medium">Unknown VPS</span>
)
return dataGridCellStack(link, ip)
},
},
{
key: 'dns',
header: 'DNS',
icon: GlobeIcon,
sortValue: (row) => row.vps?.dns ?? '',
cell: (row) => row.vps?.dns || '—',
},
{
key: 'hoster',
header: 'Хостер',
sortValue: (row) => row.vps?.providerName ?? '',
cell: (row) => row.vps?.providerName || '—',
},
{
key: 'country',
header: 'Страна',
icon: MapPinIcon,
sortValue: (row) => row.vps?.country ?? '',
cell: (row) =>
row.vps?.country
? dataGridCellWithFlag(<CountryFlag country={row.vps.country} />, row.vps.country)
: '—',
},
{
key: 'resources',
header: 'Ресурсы',
sortValue: (row) => row.vps?.vcpu ?? 0,
cell: (row) =>
row.vps
? formatVpsResources(row.vps.vcpu, row.vps.ramGb, row.vps.diskGb)
: '—',
},
{
key: 'summary',
header: 'Сводка',
cell: (row) => <SummaryBadges run={row} />,
},
{
key: 'checked',
header: 'Проверено',
sortValue: (row) => row.createdAt,
cell: (row) => formatCheckedAt(row.createdAt),
},
]
const serviceColumns: DataGridColumn<BlockingServiceRow>[] = [
{
key: 'service',
header: 'Сервис',
icon: ShieldAlertIcon,
sortValue: (row) => row.serviceKey,
cell: (row) => dataGridCellStack(row.serviceLabel, row.category),
},
{
key: 'probes',
header: 'Пробы',
sortValue: (row) => row.probes.length,
sortingFn: 'basic',
cell: (row) => row.probes.length,
},
]
export function BlockingVpsGrid({
runs,
onRowClick,
emptyAction,
}: {
runs: CensorcheckRunDto[]
onRowClick: (run: CensorcheckRunDto) => void
emptyAction?: ReactNode
}) {
return (
<ExpandableResourceGrid
columns={columnDefFromDataGrid(vpsColumns)}
data={runs}
rowId={(row) => row.id}
dense
pagination={runs.length > 10}
emptyTitle="Нет проверок"
emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок."
emptyAction={emptyAction}
onRowClick={onRowClick}
getRowCanExpand={(row) => (row.results?.length ?? 0) > 0}
expandedContent={(row) => (
<NestedList
rows={(row.results ?? []).map((item) => ({
key: item.id,
primary: item.serviceLabel,
secondary: item.category,
status: item.status,
}))}
/>
)}
/>
)
}
export function BlockingServiceGrid({
groups,
emptyAction,
}: {
groups: BlockingServiceRow[]
emptyAction?: ReactNode
}) {
return (
<ExpandableResourceGrid
columns={columnDefFromDataGrid(serviceColumns)}
data={groups}
rowId={(row) => row.id}
dense
pagination={groups.length > 10}
emptyTitle="Нет сервисов"
emptyAction={emptyAction}
getRowCanExpand={(row) => row.probes.length > 0}
expandedContent={(row) => (
<NestedList
rows={row.probes.map((probe) => ({
key: `${probe.runId}-${probe.probePublicIp}`,
primary: probe.dns || probe.probePublicIp,
secondary: `${probe.probePublicIp} · ${formatCheckedAt(probe.createdAt)}`,
status: probe.status,
}))}
/>
)}
/>
)
}
@@ -0,0 +1,268 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BanIcon,
CopyIcon,
GlobeIcon,
ServerIcon,
ShieldAlertIcon,
} from 'lucide-react'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button'
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { KpiStatGrid, ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
import { Filters, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { copyText } from '@/lib/clipboard'
import { useSpaceId } from '@/lib/space'
import {
censorcheckCurrentQueryOptions,
censorcheckHistoryQueryOptions,
} from '@/queries/censorcheck'
import { StatusBadge } from '@/components/status-badge'
import type { DataGridColumn } from '@/components/data-grid-types'
import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid'
import { CheckRunSheet } from './check-run-sheet'
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters'
import {
CENSORCHECK_STATUS_LABELS,
LAUNCHER_CMD,
formatCheckedAt,
type CensorcheckRunDto,
} from './types'
type GroupMode = 'vps' | 'service'
type TabId = 'current' | 'history'
const FILTER_FIELDS: FilterFieldConfig[] = [
{ key: 'q', label: 'Поиск', type: 'text', defaultOperator: 'contains', placeholder: 'IP, DNS, хостер, сервис' },
{
key: 'status',
label: 'Статус',
type: 'multiselect',
defaultOperator: 'is_any_of',
options: [
{ value: 'available', label: 'Доступен' },
{ value: 'blocked', label: 'Заблокирован' },
{ value: 'denied', label: 'Отказ' },
{ value: 'timeout', label: 'Таймаут' },
{ value: 'redirected', label: 'Редирект' },
{ value: 'error', label: 'Ошибка' },
],
},
{ key: 'service', label: 'Сервис', type: 'text', defaultOperator: 'is_any_of' },
{ key: 'hoster', label: 'Хостер', type: 'text', defaultOperator: 'contains' },
{ key: 'country', label: 'Страна', type: 'text', defaultOperator: 'contains' },
{
key: 'matched',
label: 'Привязка',
type: 'select',
defaultOperator: 'is',
options: [
{ value: 'matched', label: 'Известный VPS' },
{ value: 'unmatched', label: 'Unknown VPS' },
],
},
]
const historyColumns: DataGridColumn<CensorcheckRunDto>[] = [
{
key: 'ip',
header: 'IP',
sortValue: (row) => row.probePublicIp,
cell: (row) => row.probePublicIp,
},
{
key: 'vps',
header: 'VPS',
sortValue: (row) => row.vps?.dns ?? '',
cell: (row) => row.vps?.dns || (row.matchedVpsId ? row.matchedVpsId : 'Unknown VPS'),
},
{
key: 'status',
header: 'Статус',
cell: (row) => (
<StatusBadge status={row.status} label={CENSORCHECK_STATUS_LABELS[row.status] ?? row.status} />
),
},
{
key: 'summary',
header: 'Блок / всего',
sortValue: (row) => row.summary.blocked,
sortingFn: 'basic',
cell: (row) => `${row.summary.blocked} / ${row.summary.total}`,
},
{
key: 'createdAt',
header: 'Проверено',
sortValue: (row) => row.createdAt,
cell: (row) => formatCheckedAt(row.createdAt),
},
]
export function BlockingPage() {
const { spaceId } = useSpaceId()
const [tab, setTab] = useState<TabId>('current')
const [group, setGroup] = useState<GroupMode>('vps')
const [filters, setFilters] = useState<Filter[]>([])
const [selected, setSelected] = useState<CensorcheckRunDto | null>(null)
const currentQuery = useQuery(censorcheckCurrentQueryOptions(spaceId))
const historyQuery = useQuery({
...censorcheckHistoryQueryOptions({ limit: 50 }, spaceId),
enabled: tab === 'history',
})
const runs = currentQuery.data?.items ?? []
const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters])
const serviceGroups = useMemo(() => groupRunsByService(filtered), [filtered])
const matched = filtered.filter((row) => row.matchedVpsId).length
const blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0)
const copyLauncher = (
<Button
type="button"
variant="outline"
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
>
<CopyIcon data-icon="inline-start" />
Скопировать команду
</Button>
)
return (
<PageShell>
<PageHeader
title="Статус блокировок"
description="Проверки DPI и геоблокировок с VPS через censorcheck."
actions={copyLauncher}
/>
<KpiStatGrid
items={[
{
id: 'probes',
label: 'Пробы',
value: filtered.length,
icon: <GlobeIcon />,
},
{
id: 'matched',
label: 'Известные VPS',
value: matched,
icon: <ServerIcon />,
},
{
id: 'unmatched',
label: 'Unknown VPS',
value: filtered.length - matched,
icon: <ShieldAlertIcon />,
},
{
id: 'blocked',
label: 'Блокировки',
value: blocked,
icon: <BanIcon />,
variant: blocked > 0 ? 'destructive' : 'default',
},
]}
isLoading={currentQuery.isLoading}
/>
<CountedLineTabs
tabs={[
{ id: 'current', label: 'Текущие', count: filtered.length },
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
]}
value={tab}
onValueChange={(value) => setTab(value as TabId)}
/>
{tab === 'current' ? (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<Filters
filters={filters}
fields={FILTER_FIELDS}
onChange={setFilters}
trigger={
<Button type="button" variant="outline">
Фильтры
</Button>
}
/>
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
value={[group]}
onValueChange={(next) => {
const selectedMode = next[0]
if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode)
}}
aria-label="Группировка"
>
<ToggleGroupItem value="vps">По VPS</ToggleGroupItem>
<ToggleGroupItem value="service">По сервису</ToggleGroupItem>
</ToggleGroup>
</div>
<QueryState
data={filtered}
isLoading={currentQuery.isLoading}
isError={currentQuery.isError}
error={currentQuery.error}
onRetry={() => void currentQuery.refetch()}
empty={filtered.length === 0}
emptyTitle="Пока нет проверок"
emptyDescription={`На VPS выполните: ${LAUNCHER_CMD}`}
emptyAction={copyLauncher}
skeleton={<TableSkeleton />}
>
{(rows) =>
group === 'vps' ? (
<BlockingVpsGrid
runs={rows}
onRowClick={setSelected}
emptyAction={copyLauncher}
/>
) : (
<BlockingServiceGrid groups={serviceGroups} emptyAction={copyLauncher} />
)
}
</QueryState>
</div>
) : (
<ResourcePage
title="История проверок"
description="Все сохранённые прогоны censorcheck."
hideHeader
columns={columnDefFromDataGrid(historyColumns)}
data={historyQuery.data?.items ?? []}
getRowId={(row) => row.id}
isLoading={historyQuery.isLoading}
isError={historyQuery.isError}
error={historyQuery.error instanceof Error ? historyQuery.error : null}
onRetry={() => void historyQuery.refetch()}
onRowClick={setSelected}
emptyState={{
title: 'История пуста',
description: `На VPS выполните: ${LAUNCHER_CMD}`,
action: copyLauncher,
}}
/>
)}
<CheckRunSheet
run={selected}
open={Boolean(selected)}
onOpenChange={(open) => {
if (!open) setSelected(null)
}}
/>
</PageShell>
)
}
@@ -0,0 +1,117 @@
import { Link } from '@tanstack/react-router'
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { DetailPanel } from '@/components/reui-kit/detail-panel'
import { StatusBadge } from '@/components/status-badge'
import { censorcheckRunQueryOptions } from '@/queries/censorcheck'
import {
CENSORCHECK_STATUS_LABELS,
formatCheckedAt,
formatVpsResources,
type CensorcheckRunDto,
} from './types'
interface CheckRunSheetProps {
run: CensorcheckRunDto | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function CheckRunSheet({ run, open, onOpenChange }: CheckRunSheetProps) {
const needFetch = Boolean(run && !run.results)
const { data: fetched } = useQuery({
...censorcheckRunQueryOptions(needFetch ? run?.id ?? null : null),
})
const detail = run?.results ? run : fetched ?? run
const title = detail?.vps?.dns || detail?.probePublicIp || 'Проверка'
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>
{detail ? formatCheckedAt(detail.createdAt) : 'Загрузка…'}
</SheetDescription>
</SheetHeader>
{detail ? (
<DetailPanel>
<DetailPanel.Metrics
cards={[
{
id: 'ip',
icon: <GlobeIcon />,
label: 'IP',
description: detail.probePublicIp,
},
{
id: 'vps',
icon: <ServerIcon />,
label: 'VPS',
description: detail.matchedVpsId ? detail.vps?.dns || detail.matchedVpsId : 'Unknown VPS',
footer: detail.matchedVpsId ? (
<Link
to="/vps/$vpsId"
params={{ vpsId: detail.matchedVpsId }}
className="text-primary text-xs"
>
Открыть карточку
</Link>
) : undefined,
},
{
id: 'geo',
icon: <MapPinIcon />,
label: 'Локация',
description: detail.vps?.country || '—',
},
]}
/>
<DetailPanel.Section title="Сводка">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
status={detail.status}
label={CENSORCHECK_STATUS_LABELS[detail.status] ?? detail.status}
/>
{detail.vps ? (
<span className="text-muted-foreground text-sm">
{formatVpsResources(detail.vps.vcpu, detail.vps.ramGb, detail.vps.diskGb)}
</span>
) : null}
</div>
</DetailPanel.Section>
<DetailPanel.Section title="Сервисы">
<div className="flex flex-col gap-2">
{(detail.results ?? []).map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
<span className="text-muted-foreground text-xs">{item.category}</span>
</div>
<StatusBadge
status={item.status}
label={CENSORCHECK_STATUS_LABELS[item.status] ?? item.status}
/>
</div>
))}
</div>
</DetailPanel.Section>
</DetailPanel>
) : (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<ShieldAlertIcon className="size-4" />
Нет данных прогона
</div>
)}
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,80 @@
import type { CensorcheckSummary } from '@cfdm/shared/contracts/censorcheck'
export type CensorcheckResultDto = {
id: string
runId: string
serviceKey: string
serviceLabel: string
category: string
status: string
httpStatus: number | null
detail: string | null
}
export type CensorcheckVpsInfo = {
id: string
ip: string
dns: string
providerId: string
providerName: string
country: string
city: string
datacenter: string
vcpu: number
ramGb: number
diskGb: number
}
export type CensorcheckRunDto = {
id: string
spaceId: string
runId: string
probePublicIp: string
claimedPublicIp: string | null
matchedVpsId: string | null
status: string
schemaVersion: number
launcherVersion: string | null
censorcheckVersion: string | null
summary: CensorcheckSummary
createdAt: string
completedAt: string
observedSourceIp: string | null
vps: CensorcheckVpsInfo | null
results?: CensorcheckResultDto[]
}
export const CENSORCHECK_STATUS_LABELS: Record<string, string> = {
available: 'Доступен',
redirected: 'Редирект',
denied: 'Отказ',
blocked: 'Заблокирован',
timeout: 'Таймаут',
error: 'Ошибка',
complete: 'Полный',
partial: 'Частичный',
}
export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/cc | bash'
export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string {
return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB`
}
export function formatCheckedAt(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleString('ru-RU')
}
export function runSearchText(run: CensorcheckRunDto): string {
const parts = [
run.probePublicIp,
run.claimedPublicIp ?? '',
run.vps?.dns ?? '',
run.vps?.providerName ?? '',
run.vps?.country ?? '',
...(run.results ?? []).map((row) => `${row.serviceKey} ${row.serviceLabel}`),
]
return parts.join(' ').toLowerCase()
}
@@ -8,6 +8,7 @@ import {
FolderKanbanIcon,
LayoutDashboardIcon,
SearchIcon,
ShieldAlertIcon,
} from 'lucide-react'
import {
@@ -64,6 +65,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
<ServerIcon />
<span>Все VPS</span>
</CommandItem>
<CommandItem onSelect={() => go('/blocking')}>
<ShieldAlertIcon />
<span>Статус блокировок</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="VPS">
@@ -14,6 +14,7 @@ import {
HistoryIcon,
UsersIcon,
Network,
ShieldAlert,
} from 'lucide-react'
import {
@@ -82,6 +83,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Инфраструктура',
items: [
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/blocking', label: 'Статус блокировок', icon: ShieldAlert },
{ to: '/topology', label: 'Схема', icon: Network },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import {
FrameDataGrid,
type FrameDataGridProps,
} from './frame-data-grid'
/**
* Frame + DataGrid with expandable rows.
* Preview: https://reui.io/preview/base/components/c-data-grid-8
* Docs: https://reui.io/docs/components/base/data-grid
*/
export function ExpandableResourceGrid<TData extends object>({
expandedContent,
getRowCanExpand,
...props
}: FrameDataGridProps<TData> & {
expandedContent: (row: TData) => ReactNode
getRowCanExpand?: (row: TData) => boolean
}) {
return (
<FrameDataGrid
{...props}
expandedContent={expandedContent}
getRowCanExpand={getRowCanExpand}
/>
)
}
@@ -4,14 +4,16 @@ import {
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
getExpandedRowModel,
flexRender,
type ColumnDef,
type SortingState,
type RowSelectionState,
type VisibilityState,
type ExpandedState,
type OnChangeFn,
} from '@tanstack/react-table'
import { Columns3Icon } from 'lucide-react'
import { ChevronDownIcon, ChevronRightIcon, Columns3Icon } from 'lucide-react'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Button } from '@cfdm/ui/components/button'
@@ -125,6 +127,9 @@ export interface FrameDataGridProps<TData extends object> {
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
initialColumnVisibility?: VisibilityState
className?: string
/** Expandable rows — c-data-grid-8 / https://reui.io/preview/base/components/c-data-grid-8 */
expandedContent?: (row: TData) => ReactNode
getRowCanExpand?: (row: TData) => boolean
}
function DataGridSectionHeader({
@@ -251,9 +256,12 @@ export function FrameDataGrid<TData extends object>({
columnVisibilityStorageKey,
initialColumnVisibility,
className,
expandedContent,
getRowCanExpand,
}: FrameDataGridProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [expanded, setExpanded] = useState<ExpandedState>({})
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
const stored = columnVisibilityStorageKey
? loadStoredColumnVisibility(columnVisibilityStorageKey)
@@ -295,7 +303,42 @@ export function FrameDataGrid<TData extends object>({
meta: { cellClassName: 'w-10' },
}
const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns
const expandColumn: ColumnDef<TData, unknown> = {
id: 'expand',
header: () => null,
cell: ({ row }) =>
row.getCanExpand() ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-6 text-muted-foreground"
aria-label={row.getIsExpanded() ? 'Свернуть' : 'Развернуть'}
onClick={(event) => {
event.stopPropagation()
row.toggleExpanded()
}}
>
{row.getIsExpanded() ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</Button>
) : null,
enableSorting: false,
enableHiding: false,
meta: {
cellClassName: 'w-10',
expandedContent,
},
}
const tableColumns = [
...(expandedContent ? [expandColumn] : []),
...(enableRowSelection ? [selectColumn] : []),
...columns,
]
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
@@ -307,9 +350,11 @@ export function FrameDataGrid<TData extends object>({
state: {
sorting,
columnVisibility,
expanded,
...(enableRowSelection ? { rowSelection } : {}),
},
onSortingChange: setSorting,
onExpandedChange: setExpanded,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: enableRowSelection
? (updater) => {
@@ -325,6 +370,7 @@ export function FrameDataGrid<TData extends object>({
: undefined,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: expandedContent ? getExpandedRowModel() : undefined,
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
initialState: {
...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}),
@@ -333,6 +379,9 @@ export function FrameDataGrid<TData extends object>({
getRowId: rowId
? (row, index) => rowId(row, index)
: undefined,
getRowCanExpand: expandedContent
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
: undefined,
enableColumnPinning: pinLastColumn,
enableRowSelection,
enableHiding: enableColumnVisibility,
@@ -20,6 +20,7 @@ export {
type FrameDataGridProps,
type DataGridColumnVisibilityOption,
} from './frame-data-grid'
export { ExpandableResourceGrid } from './expandable-resource-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
+7
View File
@@ -8,12 +8,19 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'success',
ok: 'success',
paid: 'success',
available: 'success',
complete: 'success',
paused: 'secondary',
archived: 'outline',
error: 'destructive',
denied: 'destructive',
blocked: 'destructive',
running: 'info',
overdue: 'warning',
stale: 'warning',
timeout: 'warning',
redirected: 'warning',
partial: 'warning',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
+31
View File
@@ -417,6 +417,37 @@ export const api = {
actorUserId?: string | null
createdAt: string
}>>(`/api/audit?limit=${limit}`),
fetchCensorcheckCurrent: () =>
fetchApi<{ items: import('@/components/censorcheck/types').CensorcheckRunDto[] }>(
'/api/censorcheck/current',
),
fetchCensorcheckRuns: (params: {
cursor?: string
limit?: number
q?: string
status?: string
matched?: boolean
} = {}) => {
const search = new URLSearchParams()
if (params.cursor) search.set('cursor', params.cursor)
if (params.limit) search.set('limit', String(params.limit))
if (params.q) search.set('q', params.q)
if (params.status) search.set('status', params.status)
if (params.matched === true) search.set('matched', '1')
if (params.matched === false) search.set('matched', '0')
const qs = search.toString()
return fetchApi<{
items: import('@/components/censorcheck/types').CensorcheckRunDto[]
nextCursor: string | null
}>(`/api/censorcheck/runs${qs ? `?${qs}` : ''}`)
},
fetchCensorcheckRun: (id: string) =>
fetchApi<import('@/components/censorcheck/types').CensorcheckRunDto>(
`/api/censorcheck/runs/${encodeURIComponent(id)}`,
),
}
export type {
+1
View File
@@ -234,6 +234,7 @@ export function permissionForPath(pathname: string): string | null {
if (pathname.startsWith('/dashboard')) return 'vps:dashboard:read'
if (
pathname.startsWith('/vps') ||
pathname.startsWith('/blocking') ||
pathname.startsWith('/topology') ||
pathname.startsWith('/tariffs') ||
pathname.startsWith('/projects') ||
+40
View File
@@ -0,0 +1,40 @@
import { queryClient } from '../lib/queryClient'
import { api } from '../lib/api-client'
import { getStoredSpaceId } from '../lib/space'
export const censorcheckKeys = {
all: ['censorcheck'] as const,
current: (spaceId: string | null) => ['censorcheck', 'current', spaceId ?? 'default'] as const,
history: (spaceId: string | null, params: Record<string, unknown>) =>
['censorcheck', 'history', spaceId ?? 'default', params] as const,
detail: (id: string) => ['censorcheck', 'run', id] as const,
}
export const censorcheckCurrentQueryOptions = (spaceId?: string | null) => {
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
return {
queryKey: censorcheckKeys.current(id),
queryFn: () => api.fetchCensorcheckCurrent(),
staleTime: 15_000,
}
}
export const censorcheckHistoryQueryOptions = (
params: { cursor?: string; limit?: number; q?: string; status?: string; matched?: boolean } = {},
spaceId?: string | null,
) => {
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
return {
queryKey: censorcheckKeys.history(id, params),
queryFn: () => api.fetchCensorcheckRuns({ limit: 50, ...params }),
staleTime: 15_000,
}
}
export const censorcheckRunQueryOptions = (id: string | null) => ({
queryKey: censorcheckKeys.detail(id ?? ''),
queryFn: () => api.fetchCensorcheckRun(id!),
enabled: Boolean(id),
})
export { queryClient }
+21
View File
@@ -24,6 +24,7 @@ import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
import { Route as AuthProjectsRouteImport } from './routes/_auth/projects'
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
import { Route as AuthBlockingRouteImport } from './routes/_auth/blocking'
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
@@ -109,6 +110,11 @@ const AuthDashboardRoute = AuthDashboardRouteImport.update({
path: '/dashboard',
getParentRoute: () => AuthRoute,
} as any)
const AuthBlockingRoute = AuthBlockingRouteImport.update({
id: '/blocking',
path: '/blocking',
getParentRoute: () => AuthRoute,
} as any)
const AuthBalanceRoute = AuthBalanceRouteImport.update({
id: '/balance',
path: '/balance',
@@ -168,6 +174,7 @@ export interface FileRoutesByFullPath {
'/accounts': typeof AuthAccountsRoute
'/audit': typeof AuthAuditRoute
'/balance': typeof AuthBalanceRoute
'/blocking': typeof AuthBlockingRoute
'/dashboard': typeof AuthDashboardRoute
'/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRouteWithChildren
@@ -193,6 +200,7 @@ export interface FileRoutesByTo {
'/accounts': typeof AuthAccountsRoute
'/audit': typeof AuthAuditRoute
'/balance': typeof AuthBalanceRoute
'/blocking': typeof AuthBlockingRoute
'/dashboard': typeof AuthDashboardRoute
'/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRouteWithChildren
@@ -221,6 +229,7 @@ export interface FileRoutesById {
'/_auth/accounts': typeof AuthAccountsRoute
'/_auth/audit': typeof AuthAuditRoute
'/_auth/balance': typeof AuthBalanceRoute
'/_auth/blocking': typeof AuthBlockingRoute
'/_auth/dashboard': typeof AuthDashboardRoute
'/_auth/payments': typeof AuthPaymentsRoute
'/_auth/projects': typeof AuthProjectsRouteWithChildren
@@ -249,6 +258,7 @@ export interface FileRouteTypes {
| '/accounts'
| '/audit'
| '/balance'
| '/blocking'
| '/dashboard'
| '/payments'
| '/projects'
@@ -274,6 +284,7 @@ export interface FileRouteTypes {
| '/accounts'
| '/audit'
| '/balance'
| '/blocking'
| '/dashboard'
| '/payments'
| '/projects'
@@ -301,6 +312,7 @@ export interface FileRouteTypes {
| '/_auth/accounts'
| '/_auth/audit'
| '/_auth/balance'
| '/_auth/blocking'
| '/_auth/dashboard'
| '/_auth/payments'
| '/_auth/projects'
@@ -435,6 +447,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthDashboardRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/blocking': {
id: '/_auth/blocking'
path: '/blocking'
fullPath: '/blocking'
preLoaderRoute: typeof AuthBlockingRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/balance': {
id: '/_auth/balance'
path: '/balance'
@@ -553,6 +572,7 @@ interface AuthRouteChildren {
AuthAccountsRoute: typeof AuthAccountsRoute
AuthAuditRoute: typeof AuthAuditRoute
AuthBalanceRoute: typeof AuthBalanceRoute
AuthBlockingRoute: typeof AuthBlockingRoute
AuthDashboardRoute: typeof AuthDashboardRoute
AuthPaymentsRoute: typeof AuthPaymentsRoute
AuthProjectsRoute: typeof AuthProjectsRouteWithChildren
@@ -572,6 +592,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthAccountsRoute: AuthAccountsRoute,
AuthAuditRoute: AuthAuditRoute,
AuthBalanceRoute: AuthBalanceRoute,
AuthBlockingRoute: AuthBlockingRoute,
AuthDashboardRoute: AuthDashboardRoute,
AuthPaymentsRoute: AuthPaymentsRoute,
AuthProjectsRoute: AuthProjectsRouteWithChildren,
+10
View File
@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { BlockingPage } from '@/components/censorcheck/blocking-page'
import { censorcheckCurrentQueryOptions } from '@/queries/censorcheck'
export const Route = createFileRoute('/_auth/blocking')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(censorcheckCurrentQueryOptions()),
component: BlockingPage,
})
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@cfdm/ui/components/*": ["../../packages/ui/src/components/*"],