feat(blocking): добавить шкалу снимков над матрицей
Docker / build (push) Failing after 24s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-25 12:32:46 +07:00
co-authored by Cursor
parent 9254b8dc22
commit efebcaf16d
6 changed files with 334 additions and 10 deletions
+2 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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,
}
},