feat(censorcheck): добавить статус блокировок и launcher curl | bash
Docker / build (push) Failing after 25s
Docker / build (push) Failing after 25s
Прогон с VPS через HMAC-токен матчится к существующим серверам; UI /blocking показывает текущие проверки и историю. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@cfdm/ui/components/*": ["../../packages/ui/src/components/*"],
|
||||
|
||||
Reference in New Issue
Block a user