diff --git a/apps/api/src/routes/censorcheck.test.ts b/apps/api/src/routes/censorcheck.test.ts index b0b645b..882e560 100644 --- a/apps/api/src/routes/censorcheck.test.ts +++ b/apps/api/src/routes/censorcheck.test.ts @@ -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) diff --git a/apps/web/src/components/censorcheck/blocking-page.tsx b/apps/web/src/components/censorcheck/blocking-page.tsx index 8a3ba8f..0575d15 100644 --- a/apps/web/src/components/censorcheck/blocking-page.tsx +++ b/apps/web/src/components/censorcheck/blocking-page.tsx @@ -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('vps') const [filters, setFilters] = useState([]) const [selected, setSelected] = useState(null) + const [snapshotIndex, setSnapshotIndex] = useState(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() { {tab === 'current' ? ( -
+
+
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 ( + + +
+
+ + {isLatest ? 'Сейчас' : formatSnapshotTickLabel(selected.key)} + + + {formatCheckedAt(selected.asOf)} + +
+ + {isLatest ? 'Актуально' : 'Снимок'} + +
+ + + +
+ 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 ( + onIndexChange(tickIndex)} + > + + + + {formatSnapshotTickLabel(tick.key)} + + + + + ) + })} + +
+
+ + ) +} diff --git a/apps/web/src/components/censorcheck/blocking-snapshots.test.ts b/apps/web/src/components/censorcheck/blocking-snapshots.test.ts new file mode 100644 index 0000000..38c8e60 --- /dev/null +++ b/apps/web/src/components/censorcheck/blocking-snapshots.test.ts @@ -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 => ({ + 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) + }) +}) diff --git a/apps/web/src/components/censorcheck/blocking-snapshots.ts b/apps/web/src/components/censorcheck/blocking-snapshots.ts new file mode 100644 index 0000000..8686f96 --- /dev/null +++ b/apps/web/src/components/censorcheck/blocking-snapshots.ts @@ -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() + 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() + 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() + 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) +} diff --git a/packages/db/src/repositories/censorcheck.ts b/packages/db/src/repositories/censorcheck.ts index 2463e7b..538e5ad 100644 --- a/packages/db/src/repositories/censorcheck.ts +++ b/packages/db/src/repositories/censorcheck.ts @@ -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, } },