Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -154,8 +154,9 @@ describe('censorcheck ingest + reads', () => {
|
|||||||
await post(ingestPayload())
|
await post(ingestPayload())
|
||||||
const list = await app.inject({ method: 'GET', url: '/api/censorcheck/runs?limit=10' })
|
const list = await app.inject({ method: 'GET', url: '/api/censorcheck/runs?limit=10' })
|
||||||
expect(list.statusCode).toBe(200)
|
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).toHaveLength(1)
|
||||||
|
expect(items[0]!.results).toHaveLength(1)
|
||||||
const detail = await app.inject({ method: 'GET', url: `/api/censorcheck/runs/${items[0]!.id}` })
|
const detail = await app.inject({ method: 'GET', url: `/api/censorcheck/runs/${items[0]!.id}` })
|
||||||
expect(detail.statusCode).toBe(200)
|
expect(detail.statusCode).toBe(200)
|
||||||
expect(detail.json().results).toHaveLength(1)
|
expect(detail.json().results).toHaveLength(1)
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ import {
|
|||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid'
|
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 { CheckRunSheet } from './check-run-sheet'
|
||||||
import { filterCensorcheckRuns, serviceMatrixRows } from './blocking-filters'
|
import { filterCensorcheckRuns, serviceMatrixRows } from './blocking-filters'
|
||||||
import {
|
import {
|
||||||
@@ -110,15 +117,26 @@ export function BlockingPage() {
|
|||||||
const [group, setGroup] = useState<GroupMode>('vps')
|
const [group, setGroup] = useState<GroupMode>('vps')
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
const [selected, setSelected] = useState<CensorcheckRunDto | null>(null)
|
const [selected, setSelected] = useState<CensorcheckRunDto | null>(null)
|
||||||
|
const [snapshotIndex, setSnapshotIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
const currentQuery = useQuery(censorcheckCurrentQueryOptions(spaceId))
|
const currentQuery = useQuery(censorcheckCurrentQueryOptions(spaceId))
|
||||||
const historyQuery = useQuery({
|
const historyQuery = useQuery(censorcheckHistoryQueryOptions({ limit: 200 }, spaceId))
|
||||||
...censorcheckHistoryQueryOptions({ limit: 50 }, spaceId),
|
|
||||||
enabled: tab === 'history',
|
|
||||||
})
|
|
||||||
|
|
||||||
const runs = currentQuery.data?.items ?? []
|
const allRuns = useMemo(
|
||||||
const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters])
|
() => 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 serviceGroups = useMemo(() => serviceMatrixRows(filtered), [filtered])
|
||||||
|
|
||||||
const matched = filtered.filter((row) => row.matchedVpsId).length
|
const matched = filtered.filter((row) => row.matchedVpsId).length
|
||||||
@@ -175,7 +193,7 @@ export function BlockingPage() {
|
|||||||
|
|
||||||
<CountedLineTabs
|
<CountedLineTabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'current', label: 'Текущие', count: filtered.length },
|
{ id: 'current', label: 'Текущие', count: currentQuery.data?.items.length },
|
||||||
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
|
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
|
||||||
]}
|
]}
|
||||||
value={tab}
|
value={tab}
|
||||||
@@ -183,7 +201,12 @@ export function BlockingPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{tab === 'current' ? (
|
{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">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Filters
|
<Filters
|
||||||
filters={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 page = rows.slice(0, limit)
|
||||||
const last = page[page.length - 1]
|
const last = page[page.length - 1]
|
||||||
return {
|
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,
|
nextCursor: rows.length > limit && last ? encodeCursor(last.createdAt, last.id) : null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user