Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -154,8 +154,9 @@ describe('censorcheck ingest + reads', () => {
|
||||
await post(ingestPayload())
|
||||
const list = await app.inject({ method: 'GET', url: '/api/censorcheck/runs?limit=10' })
|
||||
expect(list.statusCode).toBe(200)
|
||||
const items = list.json().items as Array<{ id: string }>
|
||||
const items = list.json().items as Array<{ id: string; results?: unknown[] }>
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]!.results).toHaveLength(1)
|
||||
const detail = await app.inject({ method: 'GET', url: `/api/censorcheck/runs/${items[0]!.id}` })
|
||||
expect(detail.statusCode).toBe(200)
|
||||
expect(detail.json().results).toHaveLength(1)
|
||||
|
||||
@@ -26,6 +26,13 @@ import {
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid'
|
||||
import { BlockingSnapshotScrubber } from './blocking-snapshot-scrubber'
|
||||
import {
|
||||
collectSnapshotTicks,
|
||||
latestRunsAsOf,
|
||||
mergeCensorcheckRuns,
|
||||
resolveSnapshotIndex,
|
||||
} from './blocking-snapshots'
|
||||
import { CheckRunSheet } from './check-run-sheet'
|
||||
import { filterCensorcheckRuns, serviceMatrixRows } from './blocking-filters'
|
||||
import {
|
||||
@@ -110,15 +117,26 @@ export function BlockingPage() {
|
||||
const [group, setGroup] = useState<GroupMode>('vps')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [selected, setSelected] = useState<CensorcheckRunDto | null>(null)
|
||||
const [snapshotIndex, setSnapshotIndex] = useState<number | null>(null)
|
||||
|
||||
const currentQuery = useQuery(censorcheckCurrentQueryOptions(spaceId))
|
||||
const historyQuery = useQuery({
|
||||
...censorcheckHistoryQueryOptions({ limit: 50 }, spaceId),
|
||||
enabled: tab === 'history',
|
||||
})
|
||||
const historyQuery = useQuery(censorcheckHistoryQueryOptions({ limit: 200 }, spaceId))
|
||||
|
||||
const runs = currentQuery.data?.items ?? []
|
||||
const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters])
|
||||
const allRuns = useMemo(
|
||||
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
|
||||
[currentQuery.data?.items, historyQuery.data?.items],
|
||||
)
|
||||
const ticks = useMemo(() => collectSnapshotTicks(allRuns), [allRuns])
|
||||
const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
|
||||
const snapshotRuns = useMemo(() => {
|
||||
const asOf = ticks[resolvedIndex]?.asOf
|
||||
if (!asOf) return currentQuery.data?.items ?? []
|
||||
return latestRunsAsOf(allRuns, asOf)
|
||||
}, [allRuns, currentQuery.data?.items, resolvedIndex, ticks])
|
||||
const filtered = useMemo(
|
||||
() => filterCensorcheckRuns(snapshotRuns, filters),
|
||||
[snapshotRuns, filters],
|
||||
)
|
||||
const serviceGroups = useMemo(() => serviceMatrixRows(filtered), [filtered])
|
||||
|
||||
const matched = filtered.filter((row) => row.matchedVpsId).length
|
||||
@@ -175,7 +193,7 @@ export function BlockingPage() {
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'current', label: 'Текущие', count: filtered.length },
|
||||
{ id: 'current', label: 'Текущие', count: currentQuery.data?.items.length },
|
||||
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
|
||||
]}
|
||||
value={tab}
|
||||
@@ -183,7 +201,12 @@ export function BlockingPage() {
|
||||
/>
|
||||
|
||||
{tab === 'current' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex min-w-0 w-full flex-col gap-3">
|
||||
<BlockingSnapshotScrubber
|
||||
ticks={ticks}
|
||||
index={resolvedIndex}
|
||||
onIndexChange={setSnapshotIndex}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Filters
|
||||
filters={filters}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
} from '@/components/reui/timeline'
|
||||
import { Slider } from '@cfdm/ui/components/slider'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
import {
|
||||
formatSnapshotTickLabel,
|
||||
type BlockingSnapshotTick,
|
||||
} from './blocking-snapshots'
|
||||
import { formatCheckedAt } from './types'
|
||||
|
||||
/** DNA: c-timeline-12 + shadcn Slider. Preview: https://reui.io/preview/base/components/c-timeline-12 */
|
||||
export function BlockingSnapshotScrubber({
|
||||
ticks,
|
||||
index,
|
||||
onIndexChange,
|
||||
}: {
|
||||
ticks: BlockingSnapshotTick[]
|
||||
index: number
|
||||
onIndexChange: (index: number) => void
|
||||
}) {
|
||||
if (ticks.length === 0) return null
|
||||
|
||||
const selected = ticks[index] ?? ticks[ticks.length - 1]!
|
||||
const lastIndex = ticks.length - 1
|
||||
const isLatest = index >= lastIndex
|
||||
const max = Math.max(0, lastIndex)
|
||||
|
||||
const handleSlider = (next: number | readonly number[]) => {
|
||||
const value = Array.isArray(next) ? next[0] : next
|
||||
if (typeof value === 'number') onIndexChange(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className="w-full min-w-0">
|
||||
<FramePanel className="flex flex-col gap-3 px-(--frame-panel-header-px) py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<span className="text-sm font-medium">
|
||||
{isLatest ? 'Сейчас' : formatSnapshotTickLabel(selected.key)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatCheckedAt(selected.asOf)}
|
||||
</span>
|
||||
</div>
|
||||
<Badge size="sm" variant={isLatest ? 'success' : 'outline'}>
|
||||
{isLatest ? 'Актуально' : 'Снимок'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
value={[index]}
|
||||
min={0}
|
||||
max={max}
|
||||
step={1}
|
||||
disabled={ticks.length <= 1}
|
||||
onValueChange={handleSlider}
|
||||
aria-label="Снимок проверок на дату"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 overflow-x-auto">
|
||||
<Timeline
|
||||
orientation="horizontal"
|
||||
value={index + 1}
|
||||
onValueChange={(step) => onIndexChange(Math.max(0, step - 1))}
|
||||
className="min-w-max"
|
||||
>
|
||||
{ticks.map((tick, tickIndex) => {
|
||||
const showLabel =
|
||||
ticks.length <= 8 ||
|
||||
tickIndex === 0 ||
|
||||
tickIndex === lastIndex ||
|
||||
tickIndex === index
|
||||
return (
|
||||
<TimelineItem
|
||||
key={tick.key}
|
||||
step={tickIndex + 1}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onIndexChange(tickIndex)}
|
||||
>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator />
|
||||
<TimelineDate
|
||||
className={cn(
|
||||
'text-[11px] tabular-nums',
|
||||
showLabel ? undefined : 'invisible',
|
||||
)}
|
||||
>
|
||||
{formatSnapshotTickLabel(tick.key)}
|
||||
</TimelineDate>
|
||||
<TimelineIndicator className="size-2.5" />
|
||||
</TimelineHeader>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
collectSnapshotTicks,
|
||||
latestRunsAsOf,
|
||||
mergeCensorcheckRuns,
|
||||
resolveSnapshotIndex,
|
||||
snapshotDayKey,
|
||||
} from './blocking-snapshots'
|
||||
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: 1,
|
||||
available: 1,
|
||||
redirected: 0,
|
||||
denied: 0,
|
||||
blocked: 0,
|
||||
timeout: 0,
|
||||
error: 0,
|
||||
},
|
||||
createdAt: '2026-08-22T10:00:00.000Z',
|
||||
completedAt: '2026-08-22T10:00:00.000Z',
|
||||
observedSourceIp: '203.0.113.10',
|
||||
vps: null,
|
||||
results: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('mergeCensorcheckRuns', () => {
|
||||
it('дедуплицирует по id, current побеждает', () => {
|
||||
const older = run({ id: 'a', summary: { ...run().summary, blocked: 0 } })
|
||||
const newer = run({ id: 'a', summary: { ...run().summary, blocked: 3 } })
|
||||
const merged = mergeCensorcheckRuns([newer], [older, run({ id: 'b' })])
|
||||
expect(merged).toHaveLength(2)
|
||||
expect(merged.find((item) => item.id === 'a')?.summary.blocked).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectSnapshotTicks', () => {
|
||||
it('группирует по дню и берёт max createdAt', () => {
|
||||
const ticks = collectSnapshotTicks([
|
||||
run({ id: '1', createdAt: '2026-08-20T08:00:00.000Z' }),
|
||||
run({ id: '2', createdAt: '2026-08-20T18:00:00.000Z' }),
|
||||
run({ id: '3', createdAt: '2026-08-21T12:00:00.000Z' }),
|
||||
])
|
||||
expect(ticks.map((tick) => tick.key)).toEqual([
|
||||
snapshotDayKey('2026-08-20T08:00:00.000Z'),
|
||||
snapshotDayKey('2026-08-21T12:00:00.000Z'),
|
||||
])
|
||||
expect(ticks[0]?.count).toBe(2)
|
||||
expect(ticks[0]?.asOf).toBe('2026-08-20T18:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('latestRunsAsOf', () => {
|
||||
it('берёт последний прогон каждого IP на момент T', () => {
|
||||
const rows = [
|
||||
run({
|
||||
id: 'old',
|
||||
probePublicIp: '1.1.1.1',
|
||||
createdAt: '2026-08-20T10:00:00.000Z',
|
||||
summary: { ...run().summary, blocked: 1 },
|
||||
}),
|
||||
run({
|
||||
id: 'mid',
|
||||
probePublicIp: '1.1.1.1',
|
||||
createdAt: '2026-08-21T10:00:00.000Z',
|
||||
summary: { ...run().summary, blocked: 2 },
|
||||
}),
|
||||
run({
|
||||
id: 'new',
|
||||
probePublicIp: '1.1.1.1',
|
||||
createdAt: '2026-08-22T10:00:00.000Z',
|
||||
summary: { ...run().summary, blocked: 3 },
|
||||
}),
|
||||
run({
|
||||
id: 'other',
|
||||
probePublicIp: '2.2.2.2',
|
||||
createdAt: '2026-08-20T12:00:00.000Z',
|
||||
}),
|
||||
]
|
||||
const asOf = latestRunsAsOf(rows, '2026-08-21T10:00:00.000Z')
|
||||
expect(asOf.map((item) => item.id).sort()).toEqual(['mid', 'other'])
|
||||
expect(asOf.find((item) => item.id === 'mid')?.summary.blocked).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSnapshotIndex', () => {
|
||||
it('без выбора — последний тик', () => {
|
||||
expect(resolveSnapshotIndex(5, null)).toBe(4)
|
||||
expect(resolveSnapshotIndex(0, null)).toBe(0)
|
||||
expect(resolveSnapshotIndex(3, 9)).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { CensorcheckRunDto } from './types'
|
||||
|
||||
export type BlockingSnapshotTick = {
|
||||
key: string
|
||||
asOf: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function snapshotDayKey(iso: string): string {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(iso)) return iso.slice(0, 10)
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso.slice(0, 10)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function formatSnapshotTickLabel(dayKey: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return dayKey
|
||||
const date = new Date(`${dayKey}T12:00:00.000Z`)
|
||||
if (Number.isNaN(date.getTime())) return dayKey
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeCensorcheckRuns(
|
||||
current: CensorcheckRunDto[],
|
||||
history: CensorcheckRunDto[],
|
||||
): CensorcheckRunDto[] {
|
||||
const map = new Map<string, CensorcheckRunDto>()
|
||||
for (const run of history) map.set(run.id, run)
|
||||
for (const run of current) map.set(run.id, run)
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
export function collectSnapshotTicks(runs: CensorcheckRunDto[]): BlockingSnapshotTick[] {
|
||||
const byDay = new Map<string, CensorcheckRunDto[]>()
|
||||
for (const run of runs) {
|
||||
const key = snapshotDayKey(run.createdAt)
|
||||
const list = byDay.get(key)
|
||||
if (list) list.push(run)
|
||||
else byDay.set(key, [run])
|
||||
}
|
||||
|
||||
return [...byDay.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, list]) => ({
|
||||
key,
|
||||
count: list.length,
|
||||
asOf: list.reduce(
|
||||
(latest, run) => (run.createdAt > latest ? run.createdAt : latest),
|
||||
list[0]!.createdAt,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Latest run per probe IP at or before `asOf`. */
|
||||
export function latestRunsAsOf(
|
||||
runs: CensorcheckRunDto[],
|
||||
asOf: string,
|
||||
): CensorcheckRunDto[] {
|
||||
if (!asOf) return []
|
||||
const byIp = new Map<string, CensorcheckRunDto>()
|
||||
for (const run of runs) {
|
||||
if (run.createdAt > asOf) continue
|
||||
const previous = byIp.get(run.probePublicIp)
|
||||
if (
|
||||
!previous ||
|
||||
run.createdAt > previous.createdAt ||
|
||||
(run.createdAt === previous.createdAt && run.id > previous.id)
|
||||
) {
|
||||
byIp.set(run.probePublicIp, run)
|
||||
}
|
||||
}
|
||||
return [...byIp.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||
}
|
||||
|
||||
export function resolveSnapshotIndex(
|
||||
tickCount: number,
|
||||
selected: number | null,
|
||||
): number {
|
||||
if (tickCount <= 0) return 0
|
||||
if (selected == null) return tickCount - 1
|
||||
return Math.min(Math.max(selected, 0), tickCount - 1)
|
||||
}
|
||||
@@ -334,7 +334,7 @@ export const censorcheckRepository = {
|
||||
const page = rows.slice(0, limit)
|
||||
const last = page[page.length - 1]
|
||||
return {
|
||||
items: page.map((row) => toRunDto(row, false)),
|
||||
items: page.map((row) => toRunDto(row, true)),
|
||||
nextCursor: rows.length > limit && last ? encodeCursor(last.createdAt, last.id) : null,
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user