feat(web): шкала времени и наглядные матрицы на страницах блокировок и GeoIP
- шкала по часовым точкам измерений: капсулы с долей проблемных прогонов, пресеты «Сейчас / −1 ч / −6 ч / −24 ч / −7 д», кнопки и клавиши ← → - статусы блокировок — цветные IconTile, короткие подписи сервисов с иконками брендов, колонка «Доступно X из Y» с полосой - GeoIP: флаги стран в ячейках, расхождение с большинством подсвечено, колонка «Страна · совпадает N/M»; расхождения считаются по сервисам - флаг страны VPS в первой колонке, фильтры и вид в шапке матрицы - демо-данные: неделя истории прогонов по полному списку сервисов Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a4a8b9562d
commit
8ae07628c6
15 files changed
+564
-224
No files matched your search
@@ -174,56 +174,92 @@ for (const [key, status] of Object.entries(health)) {
|
|||||||
.run(`nlog-demo-${i}`, event, channel, status, message, new Date(Date.now() - (i + 1) * 47 * 60_000).toISOString()),
|
.run(`nlog-demo-${i}`, event, channel, status, message, new Date(Date.now() - (i + 1) * 47 * 60_000).toISOString()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- censorcheck (blocking) & ipregion (GeoIP) via the real ingest API
|
// --- censorcheck (blocking) & ipregion (GeoIP) via the real ingest API, with a week of history
|
||||||
const ccServices: [string, Record<string, number>][] = [
|
const HOURS_AGO = [166, 142, 118, 94, 70, 48, 30, 18, 9, 4, 0] // points on the snapshot timeline
|
||||||
['youtube.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }],
|
const CC_DPI = [
|
||||||
['instagram.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }],
|
'youtube.com', 'redirector.googlevideo.com', 'discord.com', 'instagram.com', 'facebook.com', 'x.com',
|
||||||
['chatgpt.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }],
|
'linkedin.com', 'rutracker.org', 'digitalocean.com', 'amnezia.org', 'getoutline.org', 'mailfence.com',
|
||||||
['discord.com', { vpn1: 200, vpn2: 0, vpn3: 200, web1: 200 }],
|
'flibusta.is', 'rezka.ag', 'api.telegram.org', 'play.google.com',
|
||||||
['x.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }],
|
|
||||||
['netflix.com', { vpn1: 302, vpn2: 200, vpn3: 403, web1: 200 }],
|
|
||||||
['spotify.com', { vpn1: 200, vpn2: 200, vpn3: 403, web1: 200 }],
|
|
||||||
['linkedin.com', { vpn1: 200, vpn2: 200, vpn3: 200, web1: 200 }],
|
|
||||||
]
|
]
|
||||||
const auth = () => ({ authorization: `Bearer ${mintIngestToken(SECRET)}` })
|
const CC_GEO = [
|
||||||
let n = 0
|
'spotify.com', 'netflix.com', 'patreon.com', 'swagger.io', 'snyk.io', 'mongodb.com', 'autodesk.com',
|
||||||
for (const key of ['vpn1', 'vpn2', 'vpn3', 'web1']) {
|
'graylog.org', 'redis.io', 'copilot.microsoft.com',
|
||||||
const ip = vpsList.find((v) => v.key === key)!.ip as string
|
]
|
||||||
n++
|
const RU_DPI = new Set(['youtube.com', 'redirector.googlevideo.com', 'discord.com', 'instagram.com', 'facebook.com', 'x.com', 'linkedin.com', 'rutracker.org', 'flibusta.is', 'rezka.ag', 'amnezia.org', 'getoutline.org'])
|
||||||
await api('POST', '/api/integrations/censorcheck/runs', {
|
/** HTTP code a probe sees: 200 ok, 403 geoblock, -1 DPI block, 0 timeout. step = index in HOURS_AGO. */
|
||||||
schemaVersion: 1,
|
function ccCode(key: string, service: string, step: number): number {
|
||||||
runId: `00000000-0000-4000-8000-00000000000${n}`,
|
if (key === 'mon') return RU_DPI.has(service) ? (service === 'youtube.com' && step < 4 ? 200 : -1) : CC_GEO.includes(service) && ['netflix.com', 'copilot.microsoft.com'].includes(service) ? 403 : 200
|
||||||
probe: { publicIp: ip },
|
if (key === 'vpn3') {
|
||||||
launcherVersion: '1',
|
if (['spotify.com', 'netflix.com', 'patreon.com', 'copilot.microsoft.com'].includes(service)) return 403
|
||||||
censorcheck: { version: '12c5839', mode: 'both' },
|
if (['instagram.com', 'facebook.com'].includes(service) && step >= 6) return -1
|
||||||
results: ccServices.map(([service, codes]) => ({ service, raw: { https: { ipv4: { status: codes[key] } } } })),
|
return 200
|
||||||
}, auth())
|
}
|
||||||
|
if (key === 'vpn2' && service === 'discord.com' && step % 3 === 1) return 0
|
||||||
|
if (key === 'vpn1' && service === 'netflix.com') return 302
|
||||||
|
return 200
|
||||||
}
|
}
|
||||||
const geo: Record<string, string> = { vpn1: 'FI', vpn2: 'DE', vpn3: 'KZ', web1: 'NL', us1: 'US' }
|
const auth = () => ({ authorization: `Bearer ${mintIngestToken(SECRET)}` })
|
||||||
|
const ipOf = (key: string) => vpsList.find((v) => v.key === key)!.ip as string
|
||||||
|
const backdate = (table: string, runId: string, hoursAgo: number) => {
|
||||||
|
const at = new Date(Date.now() - hoursAgo * 3600_000 - Math.floor(Math.random() * 20) * 60_000).toISOString()
|
||||||
|
sqlite.prepare(`UPDATE ${table} SET createdAt = ?, completedAt = ? WHERE runId = ?`).run(at, at, runId)
|
||||||
|
}
|
||||||
|
let n = 0
|
||||||
|
const uuid = (kind: number) => `00000000-0000-4000-${kind}000-${String(++n).padStart(12, '0')}`
|
||||||
|
for (const key of ['web1', 'vpn1', 'vpn2', 'vpn3', 'mon', 'us1']) {
|
||||||
|
for (const [step, hoursAgo] of HOURS_AGO.entries()) {
|
||||||
|
const runId = uuid(8)
|
||||||
|
await api('POST', '/api/integrations/censorcheck/runs', {
|
||||||
|
schemaVersion: 1,
|
||||||
|
runId,
|
||||||
|
probe: { publicIp: ipOf(key) },
|
||||||
|
launcherVersion: '1',
|
||||||
|
censorcheck: { version: '12c5839', mode: 'both' },
|
||||||
|
results: [...CC_DPI, ...CC_GEO].map((service) => ({
|
||||||
|
service,
|
||||||
|
raw: { https: { ipv4: { status: ccCode(key, service, step) } } },
|
||||||
|
})),
|
||||||
|
}, auth())
|
||||||
|
backdate('censorcheck_runs', runId, hoursAgo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keys from IPREGION_*_SERVICES so the matrix columns line up with the real vernette/ipregion list
|
// Keys from IPREGION_*_SERVICES so the matrix columns line up with the real vernette/ipregion list
|
||||||
const geoServices: [string, string][] = [
|
const geoServices: [string, string][] = [
|
||||||
['maxmind.com', 'primary'], ['rdap.db.ripe.net', 'primary'], ['ipinfo.io', 'primary'], ['cloudflare.com', 'primary'],
|
['maxmind.com', 'primary'], ['rdap.db.ripe.net', 'primary'], ['ipinfo.io', 'primary'], ['cloudflare.com', 'primary'],
|
||||||
['ipregistry.co', 'primary'], ['ipapi.co', 'primary'], ['ifconfig.co', 'primary'], ['ip2location.io', 'primary'],
|
['ipregistry.co', 'primary'], ['ipapi.co', 'primary'], ['ifconfig.co', 'primary'], ['ip2location.io', 'primary'],
|
||||||
['google', 'custom'], ['youtube', 'custom'], ['netflix', 'custom'], ['chatgpt', 'custom'],
|
['iplocation.com', 'primary'], ['country.is', 'primary'], ['ipwho.is', 'primary'], ['ip-api.com', 'primary'],
|
||||||
['cloudflare cdn', 'cdn'], ['youtube cdn', 'cdn'],
|
['google', 'custom'], ['youtube', 'custom'], ['netflix', 'custom'], ['chatgpt', 'custom'], ['spotify', 'custom'],
|
||||||
|
['apple', 'custom'], ['steam', 'custom'], ['tiktok', 'custom'],
|
||||||
|
['cloudflare cdn', 'cdn'], ['youtube cdn', 'cdn'], ['netflix cdn', 'cdn'],
|
||||||
]
|
]
|
||||||
|
const geo: Record<string, string> = { web1: 'NL', vpn1: 'FI', vpn2: 'DE', vpn3: 'KZ', mon: 'RU', us1: 'US' }
|
||||||
for (const [key, cc] of Object.entries(geo)) {
|
for (const [key, cc] of Object.entries(geo)) {
|
||||||
const ip = vpsList.find((v) => v.key === key)!.ip as string
|
for (const [step, hoursAgo] of HOURS_AGO.entries()) {
|
||||||
n++
|
const runId = uuid(9)
|
||||||
await api('POST', '/api/integrations/ipregion/runs', {
|
await api('POST', '/api/integrations/ipregion/runs', {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
runId: `00000000-0000-4000-9000-0000000000${String(n).padStart(2, '0')}`,
|
runId,
|
||||||
probe: { publicIp: ip },
|
probe: { publicIp: ipOf(key) },
|
||||||
launcherVersion: '1',
|
launcherVersion: '1',
|
||||||
ipregion: { version: '7d1c25c' },
|
ipregion: { version: '7d1c25c' },
|
||||||
results: geoServices.map(([service, group], i) => ({
|
results: geoServices.map(([service, group], i) => ({
|
||||||
service,
|
service,
|
||||||
group,
|
group,
|
||||||
// a few services see the KZ node elsewhere — the kind of mismatch the matrix highlights
|
// KZ node: some bases and services still place it in RU — the mismatch the matrix highlights
|
||||||
ipv4: key === 'vpn3' && (i === 4 || i === 10) ? 'RU' : key === 'vpn1' && i === 9 ? 'SE' : cc,
|
ipv4:
|
||||||
ipv6: key === 'web1' ? cc : null,
|
key === 'vpn3' && (i === 4 || i === 9 || i === 14 || (step >= 7 && i === 12))
|
||||||
})),
|
? 'RU'
|
||||||
}, auth())
|
: key === 'vpn1' && service === 'youtube'
|
||||||
|
? 'SE'
|
||||||
|
: key === 'us1' && service === 'tiktok'
|
||||||
|
? 'N/A'
|
||||||
|
: cc,
|
||||||
|
ipv6: key === 'web1' ? cc : null,
|
||||||
|
})),
|
||||||
|
}, auth())
|
||||||
|
backdate('ipregion_runs', runId, hoursAgo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- topology diagram
|
// --- topology diagram
|
||||||
|
|||||||
@@ -168,7 +168,8 @@ describe('collectProbeColumns', () => {
|
|||||||
describe('shortHostLabel', () => {
|
describe('shortHostLabel', () => {
|
||||||
it('отрезает типичный TLD', () => {
|
it('отрезает типичный TLD', () => {
|
||||||
expect(shortHostLabel('youtube.com')).toBe('youtube')
|
expect(shortHostLabel('youtube.com')).toBe('youtube')
|
||||||
expect(shortHostLabel('api.telegram.org')).toBe('api.telegram')
|
expect(shortHostLabel('api.telegram.org')).toBe('telegram')
|
||||||
|
expect(shortHostLabel('redirector.googlevideo.com')).toBe('googlevideo')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -121,9 +121,17 @@ export type MatrixColumn = {
|
|||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Короткие подписи колонок матрицы: полный хост остаётся в подсказке и карточке прогона. */
|
||||||
|
const HOST_ALIASES: Record<string, string> = {
|
||||||
|
'redirector.googlevideo.com': 'googlevideo',
|
||||||
|
'api.telegram.org': 'telegram',
|
||||||
|
'play.google.com': 'google play',
|
||||||
|
'copilot.microsoft.com': 'copilot',
|
||||||
|
}
|
||||||
|
|
||||||
export function shortHostLabel(value: string): string {
|
export function shortHostLabel(value: string): string {
|
||||||
const host = value.trim().split('/')[0] ?? value
|
const host = (value.trim().split('/')[0] ?? value).toLowerCase()
|
||||||
return host.replace(/\.(com|org|net|io|ag|is)$/i, '')
|
return HOST_ALIASES[host] ?? host.replace(/\.(com|org|net|io|ag|is)$/i, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const CANONICAL_SERVICES = [...CENSORCHECK_DPI_HOSTS, ...CENSORCHECK_GEOBLOCK_HOSTS]
|
const CANONICAL_SERVICES = [...CENSORCHECK_DPI_HOSTS, ...CENSORCHECK_GEOBLOCK_HOSTS]
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
|||||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
|
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||||
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||||
|
import { CountryFlag } from '@/components/country-flag'
|
||||||
|
import { Progress } from '@cfdm/ui/components/progress'
|
||||||
import {
|
import {
|
||||||
collectProbeColumns,
|
collectProbeColumns,
|
||||||
collectServiceColumns,
|
collectServiceColumns,
|
||||||
@@ -51,7 +53,10 @@ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
|
|||||||
) : (
|
) : (
|
||||||
<span className="font-medium">Unknown VPS</span>
|
<span className="font-medium">Unknown VPS</span>
|
||||||
)
|
)
|
||||||
return dataGridCellStack(link, secondary)
|
return dataGridCellWithIcon(
|
||||||
|
<CountryFlag country={row.vps?.country} className="size-5" />,
|
||||||
|
dataGridCellStack(link, secondary),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,20 +65,55 @@ export function BlockingVpsGrid({
|
|||||||
runs,
|
runs,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
|
toolbar,
|
||||||
}: {
|
}: {
|
||||||
runs: CensorcheckRunDto[]
|
runs: CensorcheckRunDto[]
|
||||||
onRowClick: (run: CensorcheckRunDto) => void
|
onRowClick: (run: CensorcheckRunDto) => void
|
||||||
emptyAction?: ReactNode
|
emptyAction?: ReactNode
|
||||||
|
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||||
|
toolbar?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
|
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
|
||||||
const columns = useMemo((): DataGridColumn<CensorcheckRunDto>[] => {
|
const columns = useMemo((): DataGridColumn<CensorcheckRunDto>[] => {
|
||||||
return [
|
return [
|
||||||
vpsIdentityColumn(),
|
vpsIdentityColumn(),
|
||||||
|
{
|
||||||
|
key: 'availability',
|
||||||
|
header: 'Доступно',
|
||||||
|
size: 150,
|
||||||
|
minSize: 130,
|
||||||
|
sortValue: (row) => (row.summary.total ? row.summary.available / row.summary.total : 0),
|
||||||
|
sortingFn: 'basic',
|
||||||
|
cell: (row) => {
|
||||||
|
const { available, total, blocked } = row.summary
|
||||||
|
const pct = total ? Math.round((available / total) * 100) : 0
|
||||||
|
// Литералы целиком — Tailwind не видит классы, собранные шаблонной строкой.
|
||||||
|
const bar =
|
||||||
|
blocked > 0
|
||||||
|
? '**:data-[slot=progress-indicator]:bg-destructive'
|
||||||
|
: pct < 100
|
||||||
|
? '**:data-[slot=progress-indicator]:bg-warning'
|
||||||
|
: '**:data-[slot=progress-indicator]:bg-success'
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium tabular-nums">
|
||||||
|
{available}
|
||||||
|
<span className="text-muted-foreground text-xs font-normal"> из {total}</span>
|
||||||
|
</span>
|
||||||
|
<Progress
|
||||||
|
value={pct}
|
||||||
|
aria-label={`Доступно ${available} из ${total}`}
|
||||||
|
className={bar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
...serviceCols.map(
|
...serviceCols.map(
|
||||||
(svc): DataGridColumn<CensorcheckRunDto> => ({
|
(svc): DataGridColumn<CensorcheckRunDto> => ({
|
||||||
key: `svc:${svc.key}`,
|
key: `svc:${svc.key}`,
|
||||||
header: svc.label,
|
header: svc.label,
|
||||||
headerTitle: svc.title,
|
headerTitle: svc.label,
|
||||||
icon: resolveServiceIcon(svc.key),
|
icon: resolveServiceIcon(svc.key),
|
||||||
className: MATRIX_CELL,
|
className: MATRIX_CELL,
|
||||||
headerClassName: MATRIX_CELL,
|
headerClassName: MATRIX_CELL,
|
||||||
@@ -100,6 +140,9 @@ export function BlockingVpsGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
|
title="Текущие пробы"
|
||||||
|
description="Доступность сервисов с каждого VPS"
|
||||||
|
toolbar={toolbar}
|
||||||
columns={columnDefFromDataGrid(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={runs}
|
data={runs}
|
||||||
rowId={(row) => row.id}
|
rowId={(row) => row.id}
|
||||||
@@ -120,11 +163,14 @@ export function BlockingServiceGrid({
|
|||||||
runs,
|
runs,
|
||||||
onProbeClick,
|
onProbeClick,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
|
toolbar,
|
||||||
}: {
|
}: {
|
||||||
groups: BlockingServiceRow[]
|
groups: BlockingServiceRow[]
|
||||||
runs: CensorcheckRunDto[]
|
runs: CensorcheckRunDto[]
|
||||||
onProbeClick: (run: CensorcheckRunDto) => void
|
onProbeClick: (run: CensorcheckRunDto) => void
|
||||||
emptyAction?: ReactNode
|
emptyAction?: ReactNode
|
||||||
|
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||||
|
toolbar?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
|
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
|
||||||
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
|
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
|
||||||
@@ -180,6 +226,9 @@ export function BlockingServiceGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
|
title="Текущие пробы"
|
||||||
|
description="Доступность сервисов с каждого VPS"
|
||||||
|
toolbar={toolbar}
|
||||||
columns={columnDefFromDataGrid(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={groups}
|
data={groups}
|
||||||
rowId={(row) => row.id}
|
rowId={(row) => row.id}
|
||||||
|
|||||||
@@ -131,7 +131,15 @@ export function BlockingPage() {
|
|||||||
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
|
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
|
||||||
[currentQuery.data?.items, historyQuery.data?.items],
|
[currentQuery.data?.items, historyQuery.data?.items],
|
||||||
)
|
)
|
||||||
const ticks = useMemo(() => collectSnapshotTicks(allRuns), [allRuns])
|
const ticks = useMemo(
|
||||||
|
() =>
|
||||||
|
collectSnapshotTicks(allRuns, (run) => {
|
||||||
|
const s = run.summary
|
||||||
|
if (s.blocked > 0) return 'er'
|
||||||
|
return s.denied + s.timeout + s.error > 0 ? 'wn' : 'ok'
|
||||||
|
}),
|
||||||
|
[allRuns],
|
||||||
|
)
|
||||||
const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
|
const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
|
||||||
const snapshotRuns = useMemo(() => {
|
const snapshotRuns = useMemo(() => {
|
||||||
const asOf = ticks[resolvedIndex]?.asOf
|
const asOf = ticks[resolvedIndex]?.asOf
|
||||||
@@ -184,6 +192,35 @@ export function BlockingPage() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const matrixToolbar = (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -237,33 +274,8 @@ export function BlockingPage() {
|
|||||||
ticks={ticks}
|
ticks={ticks}
|
||||||
index={resolvedIndex}
|
index={resolvedIndex}
|
||||||
onIndexChange={setSnapshotIndex}
|
onIndexChange={setSnapshotIndex}
|
||||||
|
issueLabel="с ограничениями"
|
||||||
/>
|
/>
|
||||||
<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
|
<QueryState
|
||||||
data={filtered}
|
data={filtered}
|
||||||
isLoading={currentQuery.isLoading}
|
isLoading={currentQuery.isLoading}
|
||||||
@@ -279,12 +291,14 @@ export function BlockingPage() {
|
|||||||
{(rows) =>
|
{(rows) =>
|
||||||
group === 'vps' ? (
|
group === 'vps' ? (
|
||||||
<BlockingVpsGrid
|
<BlockingVpsGrid
|
||||||
|
toolbar={matrixToolbar}
|
||||||
runs={rows}
|
runs={rows}
|
||||||
onRowClick={setSelected}
|
onRowClick={setSelected}
|
||||||
emptyAction={copyLauncher}
|
emptyAction={copyLauncher}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BlockingServiceGrid
|
<BlockingServiceGrid
|
||||||
|
toolbar={matrixToolbar}
|
||||||
groups={serviceGroups}
|
groups={serviceGroups}
|
||||||
runs={rows}
|
runs={rows}
|
||||||
onProbeClick={setSelected}
|
onProbeClick={setSelected}
|
||||||
|
|||||||
@@ -1,107 +1,189 @@
|
|||||||
|
import type { KeyboardEvent } from 'react'
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, HistoryIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import {
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
Timeline,
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
TimelineDate,
|
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
||||||
TimelineHeader,
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@cfdm/ui/components/tooltip'
|
||||||
TimelineIndicator,
|
|
||||||
TimelineItem,
|
|
||||||
TimelineSeparator,
|
|
||||||
} from '@/components/reui/timeline'
|
|
||||||
import { Slider } from '@cfdm/ui/components/slider'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
import {
|
import { tickIndexAtOrBefore, type BlockingSnapshotTick, type SnapshotTone } from './blocking-snapshots'
|
||||||
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 */
|
const HOUR = 60 * 60 * 1000
|
||||||
|
const PRESETS = [
|
||||||
|
{ id: 'now', label: 'Сейчас', ms: 0 },
|
||||||
|
{ id: '1h', label: '−1 ч', ms: HOUR },
|
||||||
|
{ id: '6h', label: '−6 ч', ms: 6 * HOUR },
|
||||||
|
{ id: '24h', label: '−24 ч', ms: 24 * HOUR },
|
||||||
|
{ id: '7d', label: '−7 д', ms: 7 * 24 * HOUR },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Верхняя часть капсулы — доля прогонов часа с проблемой, цветом худшего результата. */
|
||||||
|
const TONE_FILL: Record<SnapshotTone, string> = {
|
||||||
|
ok: 'bg-success',
|
||||||
|
wn: 'bg-warning',
|
||||||
|
er: 'bg-destructive',
|
||||||
|
}
|
||||||
|
const TONE_TEXT: Record<SnapshotTone, string> = { ok: 'text-success', wn: 'text-warning', er: 'text-destructive' }
|
||||||
|
|
||||||
|
function formatTickTime(iso: string, withDate = true): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
...(withDate ? { day: 'numeric', month: 'short' } : {}),
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Шкала времени по точкам измерений (час = точка). Лента капсул по образцу ReUI chart-34
|
||||||
|
* (Cron Run History): высота — число проб, цвет — худший результат часа. Timeline из ReUI
|
||||||
|
* статичен, поэтому навигация собрана из Button / ToggleGroup / Tooltip.
|
||||||
|
*/
|
||||||
export function BlockingSnapshotScrubber({
|
export function BlockingSnapshotScrubber({
|
||||||
ticks,
|
ticks,
|
||||||
index,
|
index,
|
||||||
onIndexChange,
|
onIndexChange,
|
||||||
|
describeTick,
|
||||||
|
issueLabel = 'с проблемами',
|
||||||
}: {
|
}: {
|
||||||
ticks: BlockingSnapshotTick[]
|
ticks: BlockingSnapshotTick[]
|
||||||
index: number
|
index: number
|
||||||
onIndexChange: (index: number) => void
|
onIndexChange: (index: number) => void
|
||||||
|
/** Подпись под временем выбранной точки, напр. «4 пробы · 2 блокировки». */
|
||||||
|
describeTick?: (tick: BlockingSnapshotTick) => string
|
||||||
|
issueLabel?: string
|
||||||
}) {
|
}) {
|
||||||
if (ticks.length === 0) return null
|
if (ticks.length === 0) return null
|
||||||
|
|
||||||
const selected = ticks[index] ?? ticks[ticks.length - 1]!
|
|
||||||
const lastIndex = ticks.length - 1
|
const lastIndex = ticks.length - 1
|
||||||
|
const selected = ticks[index] ?? ticks[lastIndex]!
|
||||||
const isLatest = index >= lastIndex
|
const isLatest = index >= lastIndex
|
||||||
const max = Math.max(0, lastIndex)
|
const maxCount = Math.max(...ticks.map((t) => t.count), 1)
|
||||||
|
const latestMs = new Date(ticks[lastIndex]!.asOf).getTime()
|
||||||
|
const go = (next: number) => onIndexChange(Math.min(lastIndex, Math.max(0, next)))
|
||||||
|
|
||||||
const handleSlider = (next: number | readonly number[]) => {
|
const presetIndex = (ms: number) => (ms === 0 ? lastIndex : tickIndexAtOrBefore(ticks, latestMs - ms))
|
||||||
const value = Array.isArray(next) ? next[0] : next
|
// Подсвечиваем только «Сейчас»: «−N ч» — переход к ближайшей точке, не режим.
|
||||||
if (typeof value === 'number') onIndexChange(value)
|
const activePreset = isLatest ? 'now' : undefined
|
||||||
|
|
||||||
|
const onStripKey = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === 'ArrowLeft') go(index - 1)
|
||||||
|
else if (event.key === 'ArrowRight') go(index + 1)
|
||||||
|
else if (event.key === 'Home') go(0)
|
||||||
|
else if (event.key === 'End') go(lastIndex)
|
||||||
|
else return
|
||||||
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense variant="default" spacing="sm" className="w-full min-w-0">
|
<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">
|
<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 flex-wrap items-center gap-3">
|
||||||
<div className="flex min-w-0 flex-col gap-px">
|
<IconTile variant="soft" size="sm" className={TONE_TEXT[selected.tone]} aria-hidden="true">
|
||||||
<span className="text-sm font-medium">
|
<HistoryIcon />
|
||||||
{isLatest ? 'Сейчас' : formatSnapshotTickLabel(selected.key)}
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-px">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
{isLatest ? 'Сейчас' : 'Снимок'} · {formatTickTime(selected.asOf)}
|
||||||
|
<Badge size="sm" variant={isLatest ? 'success-light' : 'outline'}>
|
||||||
|
{isLatest ? 'Актуально' : `${lastIndex - index} назад`}
|
||||||
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">
|
<span className="text-muted-foreground text-xs">
|
||||||
{formatCheckedAt(selected.asOf)}
|
{describeTick
|
||||||
|
? describeTick(selected)
|
||||||
|
: `${selected.count} проб · ${selected.issues} ${issueLabel}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge size="sm" variant={isLatest ? 'success' : 'outline'}>
|
<ToggleGroup
|
||||||
{isLatest ? 'Актуально' : 'Снимок'}
|
variant="outline"
|
||||||
</Badge>
|
size="sm"
|
||||||
|
spacing={0}
|
||||||
|
value={activePreset ? [activePreset] : []}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
const preset = PRESETS.find((p) => p.id === next[0])
|
||||||
|
if (!preset) return
|
||||||
|
const target = presetIndex(preset.ms)
|
||||||
|
go(target < 0 ? 0 : target)
|
||||||
|
}}
|
||||||
|
aria-label="Быстрый переход по времени"
|
||||||
|
>
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<ToggleGroupItem key={p.id} value={p.id}>
|
||||||
|
{p.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => go(index - 1)}
|
||||||
|
disabled={index <= 0}
|
||||||
|
aria-label="Предыдущее измерение"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => go(index + 1)}
|
||||||
|
disabled={isLatest}
|
||||||
|
aria-label="Следующее измерение"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Slider
|
<div
|
||||||
value={[index]}
|
role="toolbar"
|
||||||
min={0}
|
aria-label="Точки измерений: ← → для перехода"
|
||||||
max={max}
|
tabIndex={0}
|
||||||
step={1}
|
onKeyDown={onStripKey}
|
||||||
disabled={ticks.length <= 1}
|
className="focus-visible:ring-ring/50 flex h-10 items-end justify-between gap-1 rounded-md outline-none focus-visible:ring-2"
|
||||||
onValueChange={handleSlider}
|
>
|
||||||
aria-label="Снимок проверок на дату"
|
{ticks.map((tick, i) => (
|
||||||
/>
|
<Tooltip key={tick.key}>
|
||||||
|
<TooltipTrigger
|
||||||
<div className="min-w-0 overflow-x-auto">
|
render={
|
||||||
<Timeline
|
<button
|
||||||
orientation="horizontal"
|
type="button"
|
||||||
value={index + 1}
|
tabIndex={-1}
|
||||||
onValueChange={(step) => onIndexChange(Math.max(0, step - 1))}
|
aria-label={`${formatTickTime(tick.asOf)}: ${tick.count} проб, ${tick.issues} ${issueLabel}`}
|
||||||
className="min-w-max"
|
aria-pressed={i === index}
|
||||||
>
|
onClick={() => go(i)}
|
||||||
{ticks.map((tick, tickIndex) => {
|
className={cn(
|
||||||
const showLabel =
|
'bg-success/60 hover:bg-success/80 relative flex min-w-1.5 max-w-16 flex-1 flex-col overflow-hidden rounded-sm transition-colors',
|
||||||
ticks.length <= 8 ||
|
i === index && 'ring-foreground ring-offset-background ring-2 ring-offset-1',
|
||||||
tickIndex === 0 ||
|
)}
|
||||||
tickIndex === lastIndex ||
|
style={{ height: `${30 + (70 * tick.count) / maxCount}%` }}
|
||||||
tickIndex === index
|
/>
|
||||||
return (
|
}
|
||||||
<TimelineItem
|
>
|
||||||
key={tick.key}
|
{tick.issues > 0 ? (
|
||||||
step={tickIndex + 1}
|
<span
|
||||||
className="cursor-pointer"
|
aria-hidden="true"
|
||||||
onClick={() => onIndexChange(tickIndex)}
|
className={cn('block w-full', TONE_FILL[tick.tone])}
|
||||||
>
|
style={{ height: `${Math.max(12, (100 * tick.issues) / tick.count)}%` }}
|
||||||
<TimelineHeader>
|
/>
|
||||||
<TimelineSeparator />
|
) : null}
|
||||||
<TimelineDate
|
</TooltipTrigger>
|
||||||
className={cn(
|
<TooltipContent>
|
||||||
'text-[11px] tabular-nums',
|
{formatTickTime(tick.asOf)} · {tick.count} проб · {tick.issues} {issueLabel}
|
||||||
showLabel ? undefined : 'invisible',
|
</TooltipContent>
|
||||||
)}
|
</Tooltip>
|
||||||
>
|
))}
|
||||||
{formatSnapshotTickLabel(tick.key)}
|
</div>
|
||||||
</TimelineDate>
|
<div className="text-muted-foreground flex justify-between text-[11px] tabular-nums">
|
||||||
<TimelineIndicator className="size-2.5" />
|
<span>{formatTickTime(ticks[0]!.asOf)}</span>
|
||||||
</TimelineHeader>
|
{ticks.length > 1 ? <span>{formatTickTime(ticks[lastIndex]!.asOf)}</span> : null}
|
||||||
</TimelineItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
</div>
|
</div>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
latestRunsAsOf,
|
latestRunsAsOf,
|
||||||
mergeCensorcheckRuns,
|
mergeCensorcheckRuns,
|
||||||
resolveSnapshotIndex,
|
resolveSnapshotIndex,
|
||||||
snapshotDayKey,
|
tickIndexAtOrBefore,
|
||||||
} from './blocking-snapshots'
|
} from './blocking-snapshots'
|
||||||
import type { CensorcheckRunDto } from './types'
|
import type { CensorcheckRunDto } from './types'
|
||||||
|
|
||||||
@@ -48,18 +48,30 @@ describe('mergeCensorcheckRuns', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('collectSnapshotTicks', () => {
|
describe('collectSnapshotTicks', () => {
|
||||||
it('группирует по дню и берёт max createdAt', () => {
|
it('группирует по часу и берёт max createdAt', () => {
|
||||||
|
const ticks = collectSnapshotTicks(
|
||||||
|
[
|
||||||
|
run({ id: '1', createdAt: '2026-08-20T08:05:00.000Z' }),
|
||||||
|
run({ id: '2', createdAt: '2026-08-20T08:40:00.000Z', summary: { ...run().summary, blocked: 2 } }),
|
||||||
|
run({ id: '3', createdAt: '2026-08-20T18:00:00.000Z' }),
|
||||||
|
],
|
||||||
|
(r) => (r.summary.blocked > 0 ? 'er' : 'ok'),
|
||||||
|
)
|
||||||
|
expect(ticks.map((tick) => tick.key)).toEqual(['2026-08-20T08', '2026-08-20T18'])
|
||||||
|
expect(ticks[0]?.count).toBe(2)
|
||||||
|
expect(ticks[0]?.asOf).toBe('2026-08-20T08:40:00.000Z')
|
||||||
|
expect(ticks[0]?.tone).toBe('er')
|
||||||
|
expect(ticks[0]?.issues).toBe(1)
|
||||||
|
expect(ticks[1]?.tone).toBe('ok')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tickIndexAtOrBefore находит точку для пресета «−N ч»', () => {
|
||||||
const ticks = collectSnapshotTicks([
|
const ticks = collectSnapshotTicks([
|
||||||
run({ id: '1', createdAt: '2026-08-20T08:00:00.000Z' }),
|
run({ id: '1', createdAt: '2026-08-20T08:00:00.000Z' }),
|
||||||
run({ id: '2', createdAt: '2026-08-20T18:00:00.000Z' }),
|
run({ id: '2', createdAt: '2026-08-20T12:00:00.000Z' }),
|
||||||
run({ id: '3', createdAt: '2026-08-21T12:00:00.000Z' }),
|
|
||||||
])
|
])
|
||||||
expect(ticks.map((tick) => tick.key)).toEqual([
|
expect(tickIndexAtOrBefore(ticks, Date.parse('2026-08-20T10:00:00.000Z'))).toBe(0)
|
||||||
snapshotDayKey('2026-08-20T08:00:00.000Z'),
|
expect(tickIndexAtOrBefore(ticks, Date.parse('2026-08-20T07:00:00.000Z'))).toBe(-1)
|
||||||
snapshotDayKey('2026-08-21T12:00:00.000Z'),
|
|
||||||
])
|
|
||||||
expect(ticks[0]?.count).toBe(2)
|
|
||||||
expect(ticks[0]?.asOf).toBe('2026-08-20T18:00:00.000Z')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,26 @@ export type SnapshotRun = {
|
|||||||
probePublicIp: string
|
probePublicIp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SnapshotTone = 'ok' | 'wn' | 'er'
|
||||||
|
|
||||||
export type BlockingSnapshotTick = {
|
export type BlockingSnapshotTick = {
|
||||||
|
/** Час измерения `YYYY-MM-DDTHH` (UTC) — точка на шкале времени. */
|
||||||
key: string
|
key: string
|
||||||
asOf: string
|
asOf: string
|
||||||
count: number
|
count: number
|
||||||
|
/** Худший результат среди прогонов этого часа — цвет столбика на шкале. */
|
||||||
|
tone: SnapshotTone
|
||||||
|
/** Сколько прогонов часа с проблемой (блокировка / расхождение). */
|
||||||
|
issues: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_RANK: Record<SnapshotTone, number> = { ok: 0, wn: 1, er: 2 }
|
||||||
|
|
||||||
|
/** Ключ точки шкалы: прогоны группируются по часу — несколько проверок за день видны отдельно. */
|
||||||
|
export function snapshotHourKey(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso.slice(0, 13)
|
||||||
|
return date.toISOString().slice(0, 13)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapshotDayKey(iso: string): string {
|
export function snapshotDayKey(iso: string): string {
|
||||||
@@ -38,25 +54,42 @@ export function mergeCensorcheckRuns<T extends { id: string }>(
|
|||||||
return [...map.values()]
|
return [...map.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectSnapshotTicks(runs: SnapshotRun[]): BlockingSnapshotTick[] {
|
export function collectSnapshotTicks<T extends SnapshotRun>(
|
||||||
const byDay = new Map<string, SnapshotRun[]>()
|
runs: T[],
|
||||||
|
toneOf: (run: T) => SnapshotTone = () => 'ok',
|
||||||
|
): BlockingSnapshotTick[] {
|
||||||
|
const byHour = new Map<string, T[]>()
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
const key = snapshotDayKey(run.createdAt)
|
const key = snapshotHourKey(run.createdAt)
|
||||||
const list = byDay.get(key)
|
const list = byHour.get(key)
|
||||||
if (list) list.push(run)
|
if (list) list.push(run)
|
||||||
else byDay.set(key, [run])
|
else byHour.set(key, [run])
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...byDay.entries()]
|
return [...byHour.entries()]
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
.map(([key, list]) => ({
|
.map(([key, list]) => {
|
||||||
key,
|
const tones = list.map(toneOf)
|
||||||
count: list.length,
|
return {
|
||||||
asOf: list.reduce(
|
key,
|
||||||
(latest, run) => (run.createdAt > latest ? run.createdAt : latest),
|
count: list.length,
|
||||||
list[0]!.createdAt,
|
tone: tones.reduce<SnapshotTone>((worst, t) => (TONE_RANK[t] > TONE_RANK[worst] ? t : worst), 'ok'),
|
||||||
),
|
issues: tones.filter((t) => t !== 'ok').length,
|
||||||
}))
|
asOf: list.reduce(
|
||||||
|
(latest, run) => (run.createdAt > latest ? run.createdAt : latest),
|
||||||
|
list[0]!.createdAt,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Индекс последней точки не позже `atMs` (для пресетов «−1 ч», «−24 ч»); -1 — раньше первой точки. */
|
||||||
|
export function tickIndexAtOrBefore(ticks: BlockingSnapshotTick[], atMs: number): number {
|
||||||
|
let found = -1
|
||||||
|
ticks.forEach((tick, i) => {
|
||||||
|
if (new Date(tick.asOf).getTime() <= atMs) found = i
|
||||||
|
})
|
||||||
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Latest run per probe IP at or before `asOf`. */
|
/** Latest run per probe IP at or before `asOf`. */
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import { MATRIX_STATUS } from './status-matrix-cell'
|
|||||||
describe('MATRIX_STATUS', () => {
|
describe('MATRIX_STATUS', () => {
|
||||||
it('статусы — lucide-иконки, не текст ОК/Блок', () => {
|
it('статусы — lucide-иконки, не текст ОК/Блок', () => {
|
||||||
expect(MATRIX_STATUS.available.icon).toBe(CheckIcon)
|
expect(MATRIX_STATUS.available.icon).toBe(CheckIcon)
|
||||||
expect(MATRIX_STATUS.available.variant).toBe('success-light')
|
expect(MATRIX_STATUS.available.tone).toBe('text-success')
|
||||||
expect(MATRIX_STATUS.blocked.icon).toBe(XIcon)
|
expect(MATRIX_STATUS.blocked.icon).toBe(XIcon)
|
||||||
expect(MATRIX_STATUS.blocked.variant).toBe('destructive-light')
|
expect(MATRIX_STATUS.blocked.tone).toBe('text-destructive')
|
||||||
expect(MATRIX_STATUS.denied.icon).toBe(BanIcon)
|
expect(MATRIX_STATUS.denied.icon).toBe(BanIcon)
|
||||||
expect(MATRIX_STATUS.timeout.icon).toBe(ClockIcon)
|
expect(MATRIX_STATUS.timeout.icon).toBe(ClockIcon)
|
||||||
expect(MATRIX_STATUS.redirected.icon).toBe(CornerUpRightIcon)
|
expect(MATRIX_STATUS.redirected.icon).toBe(CornerUpRightIcon)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -17,17 +17,17 @@ import {
|
|||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types'
|
import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types'
|
||||||
|
|
||||||
/** Compact timesheet-style cell — preview: https://reui.io/preview/base/data-grid-base-4 */
|
/**
|
||||||
export const MATRIX_STATUS: Record<
|
* Статус-тайл ячейки матрицы: ReUI IconTile `soft` перекрашивается одним text-* классом
|
||||||
string,
|
* (так велит API IconTile — без ad-hoc bg). Preview: https://reui.io/docs/components/base/icon-tile
|
||||||
{ icon: LucideIcon; variant: 'success-light' | 'destructive-light' | 'destructive-outline' | 'warning-light' | 'info-light' | 'warning-outline' }
|
*/
|
||||||
> = {
|
export const MATRIX_STATUS: Record<string, { icon: LucideIcon; tone: string }> = {
|
||||||
available: { icon: CheckIcon, variant: 'success-light' },
|
available: { icon: CheckIcon, tone: 'text-success' },
|
||||||
blocked: { icon: XIcon, variant: 'destructive-light' },
|
blocked: { icon: XIcon, tone: 'text-destructive' },
|
||||||
denied: { icon: BanIcon, variant: 'destructive-outline' },
|
denied: { icon: BanIcon, tone: 'text-destructive' },
|
||||||
timeout: { icon: ClockIcon, variant: 'warning-light' },
|
timeout: { icon: ClockIcon, tone: 'text-warning' },
|
||||||
redirected: { icon: CornerUpRightIcon, variant: 'info-light' },
|
redirected: { icon: CornerUpRightIcon, tone: 'text-info' },
|
||||||
error: { icon: CircleAlertIcon, variant: 'warning-outline' },
|
error: { icon: CircleAlertIcon, tone: 'text-warning' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusMatrixCell({
|
export function StatusMatrixCell({
|
||||||
@@ -48,7 +48,7 @@ export function StatusMatrixCell({
|
|||||||
const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата'
|
const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата'
|
||||||
const mapped = status ? MATRIX_STATUS[status] : undefined
|
const mapped = status ? MATRIX_STATUS[status] : undefined
|
||||||
const Icon = mapped?.icon ?? MinusIcon
|
const Icon = mapped?.icon ?? MinusIcon
|
||||||
const variant = mapped?.variant ?? 'outline'
|
const tone = mapped?.tone ?? 'text-muted-foreground'
|
||||||
const tip = [
|
const tip = [
|
||||||
serviceLabel,
|
serviceLabel,
|
||||||
vpsLabel,
|
vpsLabel,
|
||||||
@@ -60,9 +60,9 @@ export function StatusMatrixCell({
|
|||||||
.join(' · ')
|
.join(' · ')
|
||||||
|
|
||||||
const badge = (
|
const badge = (
|
||||||
<Badge variant={variant} size="sm" radius="full" aria-label={full}>
|
<IconTile variant={mapped ? 'soft' : 'outline'} size="xs" radius="full" className={tone} aria-label={full}>
|
||||||
<Icon className="size-3" />
|
<Icon />
|
||||||
</Badge>
|
</IconTile>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { CountryFlag } from '@/components/country-flag'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -43,7 +44,10 @@ export function CountryMatrixCell({
|
|||||||
vpsLabel,
|
vpsLabel,
|
||||||
checkedAt,
|
checkedAt,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
expected,
|
||||||
}: {
|
}: {
|
||||||
|
/** Ожидаемая страна (большинство сервисов в строке) — отличие подсвечивается как расхождение. */
|
||||||
|
expected?: string | null
|
||||||
status?: string | null
|
status?: string | null
|
||||||
countryIpv4?: string | null
|
countryIpv4?: string | null
|
||||||
countryIpv6?: string | null
|
countryIpv6?: string | null
|
||||||
@@ -56,8 +60,17 @@ export function CountryMatrixCell({
|
|||||||
const badgeIso = countryBadgeText(stored)
|
const badgeIso = countryBadgeText(stored)
|
||||||
const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата'
|
const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата'
|
||||||
const display = status === 'ok' && badgeIso ? badgeIso : status ? (IPREGION_STATUS_LABELS[status] ?? status) : '—'
|
const display = status === 'ok' && badgeIso ? badgeIso : status ? (IPREGION_STATUS_LABELS[status] ?? status) : '—'
|
||||||
const variant = status ? (STATUS_VARIANT[status] ?? 'outline') : 'outline'
|
const mismatch = status === 'ok' && Boolean(badgeIso) && Boolean(expected) && badgeIso !== expected
|
||||||
|
const variant =
|
||||||
|
status === 'ok' && badgeIso
|
||||||
|
? mismatch
|
||||||
|
? 'warning-light'
|
||||||
|
: 'outline'
|
||||||
|
: status
|
||||||
|
? (STATUS_VARIANT[status] ?? 'outline')
|
||||||
|
: 'outline'
|
||||||
const tip = [
|
const tip = [
|
||||||
|
mismatch ? `Расхождение: большинство сервисов видят ${expected}` : null,
|
||||||
serviceLabel,
|
serviceLabel,
|
||||||
vpsLabel,
|
vpsLabel,
|
||||||
formatTipCountry(stored) ?? statusLabel,
|
formatTipCountry(stored) ?? statusLabel,
|
||||||
@@ -69,7 +82,8 @@ export function CountryMatrixCell({
|
|||||||
.join(' · ')
|
.join(' · ')
|
||||||
|
|
||||||
const badge = (
|
const badge = (
|
||||||
<Badge variant={variant} size="sm" radius="full" aria-label={tip}>
|
<Badge variant={variant} size="sm" radius="full" aria-label={tip} className="gap-1 tabular-nums">
|
||||||
|
{status === 'ok' && badgeIso ? <CountryFlag code={badgeIso} className="size-3.5" /> : null}
|
||||||
{display}
|
{display}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import type { Filter } from '@/components/reui/filters'
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
collectServiceColumns,
|
collectServiceColumns,
|
||||||
|
countryAgreement,
|
||||||
filterIpregionRuns,
|
filterIpregionRuns,
|
||||||
|
hasGeoDisagreement,
|
||||||
isGeoMismatch,
|
isGeoMismatch,
|
||||||
uniqueCountries,
|
uniqueCountries,
|
||||||
} from './geo-filters'
|
} from './geo-filters'
|
||||||
@@ -178,3 +180,15 @@ describe('uniqueCountries / mismatch', () => {
|
|||||||
).toBe(false)
|
).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('hasGeoDisagreement', () => {
|
||||||
|
it('сервис, видящий другую страну, — расхождение даже при совпадении с инвентарём', () => {
|
||||||
|
const base = run().results![0]!
|
||||||
|
const agree = run({ results: [base, { ...base, id: 'r3', serviceKey: 'ipinfo.io' }] })
|
||||||
|
expect(hasGeoDisagreement(agree)).toBe(false)
|
||||||
|
expect(countryAgreement(agree)).toEqual({ iso: 'NL', same: 2, total: 2 })
|
||||||
|
const split = run({ results: [base, { ...base, id: 'r3' }, { ...base, id: 'r4', countryIpv4: 'RU' }] })
|
||||||
|
expect(countryAgreement(split)).toEqual({ iso: 'NL', same: 2, total: 3 })
|
||||||
|
expect(hasGeoDisagreement(split)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -65,6 +65,27 @@ export function isGeoMismatch(run: IpregionRunDto): boolean {
|
|||||||
return inventory !== geo
|
return inventory !== geo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сколько ok-ответов совпадают с большинством: «совпадает N из M» в колонке «Страна». */
|
||||||
|
export function countryAgreement(run: IpregionRunDto): { iso: string | null; same: number; total: number } {
|
||||||
|
const iso = majorityCountry(run)
|
||||||
|
let same = 0
|
||||||
|
let total = 0
|
||||||
|
for (const row of run.results ?? []) {
|
||||||
|
const code = resultCountryIso(row.countryIpv4) || resultCountryIso(row.countryIpv6)
|
||||||
|
if (row.status !== 'ok' || !code) continue
|
||||||
|
total += 1
|
||||||
|
if (code === iso) same += 1
|
||||||
|
}
|
||||||
|
return { iso, same, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Расхождение для KPI и шкалы: страна ≠ инвентарю или хоть один сервис видит другую страну. */
|
||||||
|
export function hasGeoDisagreement(run: IpregionRunDto): boolean {
|
||||||
|
if (isGeoMismatch(run)) return true
|
||||||
|
const { same, total } = countryAgreement(run)
|
||||||
|
return same < total
|
||||||
|
}
|
||||||
|
|
||||||
export function uniqueCountries(runs: IpregionRunDto[]): string[] {
|
export function uniqueCountries(runs: IpregionRunDto[]): string[] {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
for (const run of runs) {
|
for (const run of runs) {
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import { GlobeIcon, ServerIcon } from 'lucide-react'
|
|||||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
|
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||||
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||||
|
import { CountryFlag } from '@/components/country-flag'
|
||||||
|
import { Progress } from '@cfdm/ui/components/progress'
|
||||||
import {
|
import {
|
||||||
collectProbeColumns,
|
collectProbeColumns,
|
||||||
collectServiceColumns,
|
collectServiceColumns,
|
||||||
|
countryAgreement,
|
||||||
|
majorityCountry,
|
||||||
resultByService,
|
resultByService,
|
||||||
type GeoServiceRow,
|
type GeoServiceRow,
|
||||||
} from './geo-filters'
|
} from './geo-filters'
|
||||||
@@ -51,7 +55,10 @@ function vpsIdentityColumn(): DataGridColumn<IpregionRunDto> {
|
|||||||
) : (
|
) : (
|
||||||
<span className="font-medium">Unknown VPS</span>
|
<span className="font-medium">Unknown VPS</span>
|
||||||
)
|
)
|
||||||
return dataGridCellStack(link, secondary)
|
return dataGridCellWithIcon(
|
||||||
|
<CountryFlag country={row.vps?.country} className="size-5" />,
|
||||||
|
dataGridCellStack(link, secondary),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,20 +67,50 @@ export function GeoVpsGrid({
|
|||||||
runs,
|
runs,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
|
toolbar,
|
||||||
}: {
|
}: {
|
||||||
runs: IpregionRunDto[]
|
runs: IpregionRunDto[]
|
||||||
onRowClick: (run: IpregionRunDto) => void
|
onRowClick: (run: IpregionRunDto) => void
|
||||||
emptyAction?: ReactNode
|
emptyAction?: ReactNode
|
||||||
|
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||||
|
toolbar?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
|
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
|
||||||
const columns = useMemo((): DataGridColumn<IpregionRunDto>[] => {
|
const columns = useMemo((): DataGridColumn<IpregionRunDto>[] => {
|
||||||
return [
|
return [
|
||||||
vpsIdentityColumn(),
|
vpsIdentityColumn(),
|
||||||
|
{
|
||||||
|
key: 'consensus',
|
||||||
|
header: 'Страна',
|
||||||
|
size: 170,
|
||||||
|
minSize: 150,
|
||||||
|
sortValue: (row) => majorityCountry(row) ?? '',
|
||||||
|
cell: (row) => {
|
||||||
|
const { iso, same, total } = countryAgreement(row)
|
||||||
|
const pct = total ? Math.round((same / total) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium tabular-nums">
|
||||||
|
{iso ? <CountryFlag code={iso} /> : null}
|
||||||
|
{iso ?? '—'}
|
||||||
|
<span className="text-muted-foreground text-xs font-normal">
|
||||||
|
{same}/{total}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Progress
|
||||||
|
value={pct}
|
||||||
|
aria-label={`Совпадает ${same} из ${total}`}
|
||||||
|
className={pct < 100 ? '**:data-[slot=progress-indicator]:bg-warning' : '**:data-[slot=progress-indicator]:bg-success'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
...serviceCols.map(
|
...serviceCols.map(
|
||||||
(svc): DataGridColumn<IpregionRunDto> => ({
|
(svc): DataGridColumn<IpregionRunDto> => ({
|
||||||
key: `svc:${svc.key}`,
|
key: `svc:${svc.key}`,
|
||||||
header: svc.label,
|
header: svc.label,
|
||||||
headerTitle: svc.title,
|
headerTitle: svc.label,
|
||||||
icon: resolveServiceIcon(svc.key),
|
icon: resolveServiceIcon(svc.key),
|
||||||
className: MATRIX_CELL,
|
className: MATRIX_CELL,
|
||||||
headerClassName: MATRIX_CELL,
|
headerClassName: MATRIX_CELL,
|
||||||
@@ -85,6 +122,7 @@ export function GeoVpsGrid({
|
|||||||
const item = resultByService(row, svc.key)
|
const item = resultByService(row, svc.key)
|
||||||
return (
|
return (
|
||||||
<CountryMatrixCell
|
<CountryMatrixCell
|
||||||
|
expected={majorityCountry(row)}
|
||||||
status={item?.status}
|
status={item?.status}
|
||||||
countryIpv4={item?.countryIpv4}
|
countryIpv4={item?.countryIpv4}
|
||||||
countryIpv6={item?.countryIpv6}
|
countryIpv6={item?.countryIpv6}
|
||||||
@@ -101,6 +139,9 @@ export function GeoVpsGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
|
title="Страны по сервисам"
|
||||||
|
description="Какую страну видят GeoIP-базы и сервисы"
|
||||||
|
toolbar={toolbar}
|
||||||
columns={columnDefFromDataGrid(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={runs}
|
data={runs}
|
||||||
rowId={(row) => row.id}
|
rowId={(row) => row.id}
|
||||||
@@ -121,11 +162,14 @@ export function GeoServiceGrid({
|
|||||||
runs,
|
runs,
|
||||||
onProbeClick,
|
onProbeClick,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
|
toolbar,
|
||||||
}: {
|
}: {
|
||||||
groups: GeoServiceRow[]
|
groups: GeoServiceRow[]
|
||||||
runs: IpregionRunDto[]
|
runs: IpregionRunDto[]
|
||||||
onProbeClick: (run: IpregionRunDto) => void
|
onProbeClick: (run: IpregionRunDto) => void
|
||||||
emptyAction?: ReactNode
|
emptyAction?: ReactNode
|
||||||
|
/** Фильтры и вид — в шапке рамки (MikrotikManager v2). */
|
||||||
|
toolbar?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
|
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
|
||||||
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
|
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
|
||||||
@@ -184,6 +228,9 @@ export function GeoServiceGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
|
title="Страны по сервисам"
|
||||||
|
description="Какую страну видят GeoIP-базы и сервисы"
|
||||||
|
toolbar={toolbar}
|
||||||
columns={columnDefFromDataGrid(columns)}
|
columns={columnDefFromDataGrid(columns)}
|
||||||
data={groups}
|
data={groups}
|
||||||
rowId={(row) => row.id}
|
rowId={(row) => row.id}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { GeoServiceGrid, GeoVpsGrid } from './geo-grid'
|
|||||||
import { GeoRunSheet } from './geo-run-sheet'
|
import { GeoRunSheet } from './geo-run-sheet'
|
||||||
import {
|
import {
|
||||||
filterIpregionRuns,
|
filterIpregionRuns,
|
||||||
isGeoMismatch,
|
hasGeoDisagreement,
|
||||||
serviceMatrixRows,
|
serviceMatrixRows,
|
||||||
uniqueCountries,
|
uniqueCountries,
|
||||||
} from './geo-filters'
|
} from './geo-filters'
|
||||||
@@ -135,7 +135,10 @@ export function GeoPage() {
|
|||||||
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
|
() => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []),
|
||||||
[currentQuery.data?.items, historyQuery.data?.items],
|
[currentQuery.data?.items, historyQuery.data?.items],
|
||||||
)
|
)
|
||||||
const ticks = useMemo(() => collectSnapshotTicks(allRuns), [allRuns])
|
const ticks = useMemo(
|
||||||
|
() => collectSnapshotTicks(allRuns, (run) => (hasGeoDisagreement(run) ? 'wn' : 'ok')),
|
||||||
|
[allRuns],
|
||||||
|
)
|
||||||
const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
|
const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex)
|
||||||
const snapshotRuns = useMemo(() => {
|
const snapshotRuns = useMemo(() => {
|
||||||
const asOf = ticks[resolvedIndex]?.asOf
|
const asOf = ticks[resolvedIndex]?.asOf
|
||||||
@@ -150,7 +153,7 @@ export function GeoPage() {
|
|||||||
|
|
||||||
const matched = filtered.filter((row) => row.matchedVpsId).length
|
const matched = filtered.filter((row) => row.matchedVpsId).length
|
||||||
const countries = uniqueCountries(filtered).length
|
const countries = uniqueCountries(filtered).length
|
||||||
const mismatches = filtered.filter(isGeoMismatch).length
|
const mismatches = filtered.filter(hasGeoDisagreement).length
|
||||||
|
|
||||||
const copyLauncher = (
|
const copyLauncher = (
|
||||||
<>
|
<>
|
||||||
@@ -189,6 +192,35 @@ export function GeoPage() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const matrixToolbar = (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -242,33 +274,8 @@ export function GeoPage() {
|
|||||||
ticks={ticks}
|
ticks={ticks}
|
||||||
index={resolvedIndex}
|
index={resolvedIndex}
|
||||||
onIndexChange={setSnapshotIndex}
|
onIndexChange={setSnapshotIndex}
|
||||||
|
issueLabel="с расхождениями"
|
||||||
/>
|
/>
|
||||||
<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
|
<QueryState
|
||||||
data={filtered}
|
data={filtered}
|
||||||
isLoading={currentQuery.isLoading}
|
isLoading={currentQuery.isLoading}
|
||||||
@@ -284,12 +291,14 @@ export function GeoPage() {
|
|||||||
{(rows) =>
|
{(rows) =>
|
||||||
group === 'vps' ? (
|
group === 'vps' ? (
|
||||||
<GeoVpsGrid
|
<GeoVpsGrid
|
||||||
|
toolbar={matrixToolbar}
|
||||||
runs={rows}
|
runs={rows}
|
||||||
onRowClick={setSelected}
|
onRowClick={setSelected}
|
||||||
emptyAction={copyLauncher}
|
emptyAction={copyLauncher}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<GeoServiceGrid
|
<GeoServiceGrid
|
||||||
|
toolbar={matrixToolbar}
|
||||||
groups={serviceGroups}
|
groups={serviceGroups}
|
||||||
runs={rows}
|
runs={rows}
|
||||||
onProbeClick={setSelected}
|
onProbeClick={setSelected}
|
||||||
|
|||||||
Reference in new issue
Block a user