Files
vps-tracker/apps/api/scripts/seed-demo.ts
T
DenozordecandClaude Opus 5.5 8ae07628c6 feat(web): шкала времени и наглядные матрицы на страницах блокировок и GeoIP
- шкала по часовым точкам измерений: капсулы с долей проблемных прогонов,
  пресеты «Сейчас / −1 ч / −6 ч / −24 ч / −7 д», кнопки и клавиши ← →
- статусы блокировок — цветные IconTile, короткие подписи сервисов
  с иконками брендов, колонка «Доступно X из Y» с полосой
- GeoIP: флаги стран в ячейках, расхождение с большинством подсвечено,
  колонка «Страна · совпадает N/M»; расхождения считаются по сервисам
- флаг страны VPS в первой колонке, фильтры и вид в шапке матрицы
- демо-данные: неделя истории прогонов по полному списку сервисов

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:06:21 +07:00

322 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Demo data for screenshots/docs. Fictional hosters, IPs from documentation ranges (RFC 5737).
* Needs an EMPTY database file. VITEST=1 keeps src/index.ts from starting the real server + scheduler.
*
* cd apps/api
* DB_PATH=../../data/demo.db VITEST=1 LOG_LEVEL=warn npx tsx scripts/seed-demo.ts --serve
*
* With --serve the seeded app keeps listening on PORT (default 3101) for scripts/docs-screenshots.mjs.
*/
import { getSqlite } from '@cfdm/db'
import { buildApp } from '../src/index.js'
import { mintIngestToken } from '../src/services/censorcheck/ingest-token.js'
import { stopScheduler } from '../src/services/scheduler.js'
const SECRET = process.env.CENSORCHECK_INGEST_SECRET || 'demo-secret'
process.env.CENSORCHECK_INGEST_SECRET = SECRET
process.env.CENSORCHECK_RATE_LIMIT = '0'
const app = await buildApp()
const sqlite = getSqlite()
async function api<T = { id: string }>(method: string, url: string, payload?: unknown, headers = {}): Promise<T> {
const res = await app.inject({ method: method as 'POST', url, payload: payload as object, headers })
if (res.statusCode >= 300) throw new Error(`${method} ${url} → ${res.statusCode}: ${res.body}`)
return res.json() as T
}
const day = (offset: number) => {
const d = new Date()
d.setDate(d.getDate() + offset)
return d.toISOString().slice(0, 10)
}
// --- settings
await api('PUT', '/api/settings/settings-main', {
baseCurrency: 'RUB',
ratesUrl: 'https://www.cbr-xml-daily.ru/daily_json.js',
autoConvert: true,
syncEnabled: false,
notifyPaymentExpiryEnabled: true,
notifyLowBalanceEnabled: true,
notifySyncDigestEnabled: true,
notifyVpsDownEnabled: true,
telegramChatId: '-1001234567890',
})
// --- providers & accounts
const providers = [
{ key: 'nord', name: 'Nordhost', website: 'https://nordhost.example', baseCurrency: 'EUR', eurRate: '98.5', usdRate: '91.2', apiType: 'billmanager', apiBaseUrl: 'https://my.nordhost.example/billmgr' },
{ key: 'aurora', name: 'Aurora Cloud', website: 'https://aurora-cloud.example', baseCurrency: 'RUB', apiType: 'billmanager', apiBaseUrl: 'https://billing.aurora-cloud.example/billmgr' },
{ key: 'baltic', name: 'Baltic VDS', website: 'https://balticvds.example', baseCurrency: 'RUB', apiType: 'none', apiBaseUrl: '' },
{ key: 'pixel', name: 'Pixel Hosting', website: 'https://pixelhost.example', baseCurrency: 'USD', usdRate: '91.2', apiType: 'none', apiBaseUrl: '' },
]
const prov: Record<string, string> = {}
for (const p of providers) prov[p.key] = (await api('POST', '/api/providers', p)).id
const accounts = [
{ key: 'nord', providerId: prov.nord, name: 'Nordhost — основной', currency: 'EUR', billingMode: 'monthly', apiCredentials: 'demo:demo', balanceAlertBelow: 20, balance: 46.8, enough: day(38) },
{ key: 'aurora', providerId: prov.aurora, name: 'Aurora — проекты', currency: 'RUB', billingMode: 'daily', apiCredentials: 'demo:demo', balanceAlertBelow: 1500, balance: 1120, enough: day(9) },
{ key: 'baltic', providerId: prov.baltic, name: 'Baltic VDS', currency: 'RUB', billingMode: 'monthly', balance: null, enough: '' },
{ key: 'pixel', providerId: prov.pixel, name: 'Pixel — US', currency: 'USD', billingMode: 'monthly', balance: null, enough: '' },
]
const acc: Record<string, string> = {}
for (const a of accounts) {
const { key, balance, enough, ...body } = a
acc[key] = (await api('POST', '/api/provider-accounts', body)).id
if (balance != null) {
sqlite
.prepare(`UPDATE provider_accounts SET balance_api = ?, balance_currency = ?, balance_updated_at = ?, enoughmoneyto = ? WHERE id = ?`)
.run(balance, a.currency, new Date(Date.now() - 40 * 60_000).toISOString(), enough, acc[key])
}
}
// --- projects
const projects = [
{ name: 'Сайт и API', color: '#2563eb' },
{ name: 'VPN', color: '#16a34a' },
{ name: 'Мониторинг', color: '#d97706' },
{ name: 'Staging', color: '#9333ea' },
]
for (const p of projects) await api('POST', '/api/projects', p)
// --- VPS
type V = Record<string, unknown> & { key: string }
const vpsList: V[] = [
{ key: 'web1', dns: 'web-1.example.net', ip: '203.0.113.10', ipv6: '2001:db8:10::1', providerId: prov.nord, providerAccountId: acc.nord, country: 'Нидерланды', city: 'Амстердам', datacenter: 'AMS-1', os: 'Debian 12', vcpu: 4, ramGb: 8, diskGb: 120, diskType: 'NVMe', virtualization: 'KVM', project: 'Сайт и API', environment: 'production', purpose: 'Frontend + API', tariffType: 'monthly', currency: 'EUR', monthlyRate: 14.9, paidUntil: day(4), monitoringEnabled: true, backupEnabled: true },
{ key: 'web2', dns: 'web-2.example.net', ip: '203.0.113.11', providerId: prov.nord, providerAccountId: acc.nord, country: 'Германия', city: 'Франкфурт', datacenter: 'FRA-2', os: 'Debian 12', vcpu: 4, ramGb: 8, diskGb: 120, diskType: 'NVMe', virtualization: 'KVM', project: 'Сайт и API', environment: 'production', purpose: 'Frontend + API (HA)', tariffType: 'monthly', currency: 'EUR', monthlyRate: 14.9, paidUntil: day(19), monitoringEnabled: true, backupEnabled: true },
{ key: 'db1', dns: 'db-1.example.net', ip: '203.0.113.20', providerId: prov.nord, providerAccountId: acc.nord, country: 'Нидерланды', city: 'Амстердам', datacenter: 'AMS-1', os: 'Ubuntu 24.04', vcpu: 8, ramGb: 32, diskGb: 480, diskType: 'NVMe', virtualization: 'KVM', project: 'Сайт и API', environment: 'production', purpose: 'PostgreSQL', tariffType: 'monthly', currency: 'EUR', monthlyRate: 39, paidUntil: day(19), monitoringEnabled: true, backupEnabled: true },
{ key: 'vpn1', dns: 'vpn-fi.example.net', ip: '198.51.100.31', providerId: prov.aurora, providerAccountId: acc.aurora, country: 'Финляндия', city: 'Хельсинки', datacenter: 'HEL', os: 'Ubuntu 22.04', vcpu: 1, ramGb: 1, diskGb: 15, diskType: 'SSD', virtualization: 'KVM', project: 'VPN', environment: 'production', purpose: 'WireGuard', tariffType: 'daily', currency: 'RUB', dailyRate: 9.5, paidUntil: '', monitoringEnabled: true },
{ key: 'vpn2', dns: 'vpn-de.example.net', ip: '198.51.100.32', providerId: prov.aurora, providerAccountId: acc.aurora, country: 'Германия', city: 'Нюрнберг', datacenter: 'NUE', os: 'Ubuntu 22.04', vcpu: 1, ramGb: 2, diskGb: 20, diskType: 'SSD', virtualization: 'KVM', project: 'VPN', environment: 'production', purpose: 'WireGuard + Xray', tariffType: 'daily', currency: 'RUB', dailyRate: 12, paidUntil: '', monitoringEnabled: true },
{ key: 'vpn3', dns: 'vpn-kz.example.net', ip: '198.51.100.33', providerId: prov.aurora, providerAccountId: acc.aurora, country: 'Казахстан', city: 'Алматы', datacenter: 'ALA', os: 'Debian 12', vcpu: 1, ramGb: 1, diskGb: 10, diskType: 'SSD', virtualization: 'KVM', project: 'VPN', environment: 'production', purpose: 'Xray', tariffType: 'daily', currency: 'RUB', dailyRate: 7.2, paidUntil: '', monitoringEnabled: true },
{ key: 'mon', dns: 'monitor.example.net', ip: '192.0.2.40', providerId: prov.baltic, providerAccountId: acc.baltic, country: 'Россия', city: 'Москва', datacenter: 'MSK-DC3', os: 'Rocky Linux 9', vcpu: 2, ramGb: 4, diskGb: 60, diskType: 'SSD', virtualization: 'KVM', project: 'Мониторинг', environment: 'production', purpose: 'Grafana + Prometheus', tariffType: 'monthly', currency: 'RUB', monthlyRate: 690, paidUntil: day(2), monitoringEnabled: true, backupEnabled: true },
{ key: 'stage', dns: 'stage.example.net', ip: '192.0.2.41', providerId: prov.baltic, providerAccountId: acc.baltic, country: 'Россия', city: 'Санкт-Петербург', datacenter: 'SPB-1', os: 'Debian 12', vcpu: 2, ramGb: 4, diskGb: 40, diskType: 'SSD', virtualization: 'KVM', project: 'Staging', environment: 'staging', purpose: 'Staging', tariffType: 'monthly', currency: 'RUB', monthlyRate: 450, paidUntil: day(26), monitoringEnabled: false },
{ key: 'us1', dns: 'edge-us.example.net', ip: '192.0.2.50', providerId: prov.pixel, providerAccountId: acc.pixel, country: 'США', city: 'Нью-Йорк', datacenter: 'NYC-3', os: 'Ubuntu 24.04', vcpu: 2, ramGb: 2, diskGb: 50, diskType: 'NVMe', virtualization: 'KVM', project: 'Сайт и API', environment: 'production', purpose: 'Edge / CDN origin', tariffType: 'monthly', currency: 'USD', monthlyRate: 12, paidUntil: day(11), monitoringEnabled: true },
{ key: 'old', dns: 'legacy.example.net', ip: '192.0.2.60', providerId: prov.pixel, providerAccountId: acc.pixel, country: 'США', city: 'Даллас', datacenter: 'DAL-1', os: 'CentOS 7', vcpu: 1, ramGb: 1, diskGb: 25, diskType: 'HDD', virtualization: 'OpenVZ', project: 'Staging', environment: 'staging', purpose: 'Старый сервер', status: 'paused', tariffType: 'monthly', currency: 'USD', monthlyRate: 5, paidUntil: day(-12) },
]
const vps: Record<string, string> = {}
for (const { key, ...body } of vpsList) vps[key] = (await api('POST', '/api/vps', body)).id
// --- payments (6 months) and ledger
for (let m = 5; m >= 0; m--) {
await api('POST', '/api/payments', { type: 'provider_balance_topup', date: day(-m * 30 - 3), amount: 60 + (m % 2) * 15, currency: 'EUR', providerAccountId: acc.nord, note: 'Пополнение картой' })
await api('POST', '/api/payments', { type: 'provider_balance_topup', date: day(-m * 30 - 7), amount: 900 + m * 50, currency: 'RUB', providerAccountId: acc.aurora, note: 'СБП' })
await api('POST', '/api/payments', { type: 'direct_vps_payment', date: day(-m * 30 - 1), amount: 690, currency: 'RUB', providerAccountId: acc.baltic, vpsId: vps.mon, note: 'Продление monitor' })
await api('POST', '/api/payments', { type: 'direct_vps_payment', date: day(-m * 30 - 10), amount: 12, currency: 'USD', providerAccountId: acc.pixel, vpsId: vps.us1, note: 'Продление edge-us' })
}
await api('POST', '/api/payments', { type: 'direct_vps_payment', date: day(-20), amount: 450, currency: 'RUB', providerAccountId: acc.baltic, vpsId: vps.stage, note: 'Продление staging' })
for (let i = 0; i < 6; i++) {
await api('POST', '/api/balance-ledger', { type: 'daily_debit', date: day(-i), amount: 28.7, currency: 'RUB', direction: 'debit', providerAccountId: acc.aurora, note: 'Списание за VPN-серверы' })
}
await api('POST', '/api/balance-ledger', { type: 'provider_balance_topup', date: day(-7), amount: 1200, currency: 'RUB', direction: 'credit', providerAccountId: acc.aurora, note: 'Пополнение' })
// --- tariffs from "sync"
const tariffs = [
['AMS-1', 'Нидерланды', 'KVM-S', 2, 4, 60, '7.90 EUR'], ['AMS-1', 'Нидерланды', 'KVM-M', 4, 8, 120, '14.90 EUR'],
['AMS-1', 'Нидерланды', 'KVM-L', 8, 32, 480, '39.00 EUR'], ['FRA-2', 'Германия', 'KVM-S', 2, 4, 60, '8.50 EUR'],
['FRA-2', 'Германия', 'KVM-M', 4, 8, 120, '14.90 EUR'],
] as const
const now = new Date().toISOString()
tariffs.forEach(([dc, country, name, cpu, ram, disk, price], i) =>
sqlite
.prepare(
`INSERT INTO active_tariffs (id, spaceId, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
VALUES (?, 'space-main', ?, ?, ?, ?, ?, ?, '', ?, ?, ?, 'NVMe', 'KVM', '1 Гбит/с', ?, ?, 'AMD EPYC', 1, ?, ?)`,
)
.run(`tariff-demo-${i}`, acc.nord, prov.nord, String(100 + i), dc, dc, name, cpu, ram, disk, dc, country, price, now),
)
;[
['VPN-1', 1, 1, 10, '7.20 ₽/день'], ['VPN-2', 1, 2, 20, '12.00 ₽/день'], ['Cloud-4', 4, 8, 80, '39.00 ₽/день'],
].forEach(([name, cpu, ram, disk, price], i) =>
sqlite
.prepare(
`INSERT INTO active_tariffs (id, spaceId, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
VALUES (?, 'space-main', ?, ?, ?, 'HEL', 'Хельсинки', ?, '', ?, ?, ?, 'SSD', 'KVM', '1 Гбит/с', 'Хельсинки', 'Финляндия', 'Intel Xeon', 1, ?, ?)`,
)
.run(`tariff-demo-a${i}`, acc.aurora, prov.aurora, String(200 + i), name, cpu, ram, disk, price, now),
)
// --- sync journal
const syncRows: [string, number, string, number, number, string | null][] = [
[acc.nord, 35, 'ok', 3, 1, null], [acc.aurora, 36, 'ok', 3, 0, null],
[acc.nord, 95, 'ok', 3, 0, null], [acc.aurora, 96, 'error', 0, 0, 'HTTP 502 Bad Gateway'],
[acc.nord, 155, 'ok', 3, 0, null], [acc.aurora, 156, 'ok', 3, 1, null],
]
syncRows.forEach(([accountId, minAgo, status, vpsCount, paymentsCount, error], i) => {
const started = new Date(Date.now() - minAgo * 60_000)
sqlite
.prepare(`INSERT INTO sync_log (id, spaceId, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error, summary) VALUES (?, 'space-main', ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(`sync-demo-${i}`, accountId, started.toISOString(), new Date(started.getTime() + 4200).toISOString(), status, vpsCount, paymentsCount, error,
JSON.stringify(status === 'ok' ? { added: [], updated: i === 0 ? [{ id: vps.web1, label: 'web-1.example.net', fields: ['paidUntil'] }] : [], paymentsAdded: paymentsCount, vpsCount, paymentsCount, tariffsCount: 0 } : { error }))
})
// --- uptime history
const health: Record<string, 'up' | 'down'> = { web1: 'up', web2: 'up', db1: 'up', vpn1: 'up', vpn2: 'down', vpn3: 'up', mon: 'up', us1: 'up' }
for (const [key, status] of Object.entries(health)) {
for (let i = 0; i < 12; i++) {
const st = i === 0 ? status : 'up'
sqlite
.prepare(`INSERT INTO vps_health_checks (id, spaceId, vpsId, checkedAt, status, latencyMs, error) VALUES (?, 'space-main', ?, ?, ?, ?, ?)`)
.run(`hc-demo-${key}-${i}`, vps[key], new Date(Date.now() - i * 5 * 60_000).toISOString(), st, st === 'up' ? 20 + ((i * 7) % 40) : null, st === 'down' ? 'timeout' : null)
}
sqlite.prepare(`UPDATE vps SET last_health_status = ?, last_health_checked_at = ? WHERE id = ?`).run(status, now, vps[key])
}
// --- notifications journal
;[
['payment_expiry', 'telegram', 'sent', 'Истекает оплата (ближайшие 7 дней):\n\n• monitor.example.net (Baltic VDS) — до ' + day(2)],
['vps_down', 'telegram', 'sent', 'VPS недоступны:\n\n• vpn-de.example.net'],
['low_balance', 'telegram', 'sent', 'Низкий баланс:\n\n• Aurora — проекты: 1120 RUB (порог 1500)'],
['sync_digest', 'webhook', 'sent', 'Синхронизация VPS:\n\n✓ Nordhost — основной: изм. 1, платежи +1'],
].forEach(([event, channel, status, message], i) =>
sqlite
.prepare(`INSERT INTO notification_log (id, spaceId, event, channel, status, fingerprint, message, payload, createdAt) VALUES (?, 'space-main', ?, ?, ?, '', ?, NULL, ?)`)
.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, with a week of history
const HOURS_AGO = [166, 142, 118, 94, 70, 48, 30, 18, 9, 4, 0] // points on the snapshot timeline
const CC_DPI = [
'youtube.com', 'redirector.googlevideo.com', 'discord.com', 'instagram.com', 'facebook.com', 'x.com',
'linkedin.com', 'rutracker.org', 'digitalocean.com', 'amnezia.org', 'getoutline.org', 'mailfence.com',
'flibusta.is', 'rezka.ag', 'api.telegram.org', 'play.google.com',
]
const CC_GEO = [
'spotify.com', 'netflix.com', 'patreon.com', 'swagger.io', 'snyk.io', 'mongodb.com', 'autodesk.com',
'graylog.org', 'redis.io', 'copilot.microsoft.com',
]
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'])
/** HTTP code a probe sees: 200 ok, 403 geoblock, -1 DPI block, 0 timeout. step = index in HOURS_AGO. */
function ccCode(key: string, service: string, step: number): number {
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
if (key === 'vpn3') {
if (['spotify.com', 'netflix.com', 'patreon.com', 'copilot.microsoft.com'].includes(service)) return 403
if (['instagram.com', 'facebook.com'].includes(service) && step >= 6) return -1
return 200
}
if (key === 'vpn2' && service === 'discord.com' && step % 3 === 1) return 0
if (key === 'vpn1' && service === 'netflix.com') return 302
return 200
}
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
const geoServices: [string, string][] = [
['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'],
['iplocation.com', 'primary'], ['country.is', 'primary'], ['ipwho.is', 'primary'], ['ip-api.com', 'primary'],
['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 [step, hoursAgo] of HOURS_AGO.entries()) {
const runId = uuid(9)
await api('POST', '/api/integrations/ipregion/runs', {
schemaVersion: 1,
runId,
probe: { publicIp: ipOf(key) },
launcherVersion: '1',
ipregion: { version: '7d1c25c' },
results: geoServices.map(([service, group], i) => ({
service,
group,
// KZ node: some bases and services still place it in RU — the mismatch the matrix highlights
ipv4:
key === 'vpn3' && (i === 4 || i === 9 || i === 14 || (step >= 7 && i === 12))
? 'RU'
: 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
const node = (id: string, x: number, y: number, key: string) => ({
id,
type: 'vps',
position: { x, y },
data: { vpsId: vps[key] },
})
const edge = (source: string, target: string, relation: string, extra: Record<string, unknown> = {}) => ({
id: `e-${source}-${target}`,
source,
target,
sourceHandle: 'bottom',
targetHandle: 'top',
type: 'topology',
data: { relation, direction: 'forward', lineStyle: 'solid', ...extra },
})
await api('POST', '/api/topology', {
name: 'Production',
document: {
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [
{ id: 'users', type: 'shape', position: { x: 470, y: 0 }, data: { kind: 'ellipse', label: 'Пользователи' } },
node('us1', 470, 130, 'us1'),
node('web1', 220, 290, 'web1'),
node('web2', 720, 290, 'web2'),
node('db1', 470, 460, 'db1'),
node('mon', 970, 460, 'mon'),
node('vpn1', 120, 640, 'vpn1'),
node('vpn2', 470, 640, 'vpn2'),
node('vpn3', 820, 640, 'vpn3'),
{ id: 'note', type: 'note', position: { x: 1120, y: 640 }, data: { text: 'VPN-узлы для команды: WireGuard + Xray' } },
],
edges: [
edge('users', 'us1', 'network', { label: 'HTTPS' }),
edge('us1', 'web1', 'network'),
edge('us1', 'web2', 'network'),
edge('web1', 'db1', 'dependency', { label: 'PostgreSQL' }),
edge('web2', 'db1', 'dependency'),
{ ...edge('mon', 'db1', 'dependency', { lineStyle: 'dashed', label: 'метрики' }), sourceHandle: 'left', targetHandle: 'right' },
{ ...edge('vpn1', 'vpn2', 'tunnel', { direction: 'bidirectional' }), sourceHandle: 'right', targetHandle: 'left' },
{ ...edge('vpn2', 'vpn3', 'tunnel', { direction: 'bidirectional' }), sourceHandle: 'right', targetHandle: 'left' },
],
},
})
// PUT /api/settings restarts the scheduler; its uptime checks against documentation IPs would flip
// every monitored VPS to "down", so the demo runs without it.
stopScheduler()
console.log('demo seeded:', Object.keys(vps).length, 'VPS')
if (process.argv.includes('--serve')) {
const port = Number(process.env.PORT ?? 3101)
await app.listen({ port, host: '127.0.0.1' })
console.log(`demo UI: http://127.0.0.1:${port}/dashboard`)
} else {
await app.close()
process.exit(0)
}