Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump. Co-authored-by: Cursor <[email protected]>
585 lines
20 KiB
TypeScript
585 lines
20 KiB
TypeScript
import { asc, desc, eq, sql } from "drizzle-orm"
|
|
import { db, dbAll } from "../db/index.js"
|
|
import {
|
|
alertGroups,
|
|
alertHistory,
|
|
alertRuleConditions,
|
|
alertRuleTargets,
|
|
alertRules,
|
|
alertTelegramSettings,
|
|
servers,
|
|
uptimeProbes,
|
|
} from "../db/schema.js"
|
|
|
|
export type ApiAlertGroup = {
|
|
id: string
|
|
name: string
|
|
combineMode: "any" | "all"
|
|
enabled: boolean
|
|
cooldownOverride: (typeof alertRules.$inferSelect)["cooldown"] | null
|
|
}
|
|
|
|
export type ApiAlertRule = {
|
|
id: string
|
|
name: string
|
|
type: (typeof alertRules.$inferSelect)["type"]
|
|
/** Сводная строка для списка / поиска */
|
|
target: string
|
|
/** Все объекты правила (OR) */
|
|
targets: string[]
|
|
groupId: string | null
|
|
/** Сводная строка условий для списка */
|
|
condition: string
|
|
/** Все условия (OR); если пусто в БД — используется `condition` */
|
|
conditions: string[]
|
|
severity: (typeof alertRules.$inferSelect)["severity"]
|
|
enabled: boolean
|
|
cooldown: (typeof alertRules.$inferSelect)["cooldown"]
|
|
/** Секунды стабильности перед отправкой; null — выключено */
|
|
confirmStabilitySec: number | null
|
|
/** Политика отправки recovery для этого правила. */
|
|
recoveryMode: "always" | "never" | "conditional"
|
|
/** Доп. задержка подтверждения recovery при `conditional`. */
|
|
recoveryStabilitySec: number | null
|
|
chatId: string
|
|
lastFiredAt: string | null
|
|
}
|
|
|
|
export type ApiAlertHistoryEntry = {
|
|
id: string
|
|
ruleName: string
|
|
severity: (typeof alertHistory.$inferSelect)["severity"]
|
|
message: string
|
|
sent: boolean
|
|
firedAt: string
|
|
source: "db" | "live"
|
|
}
|
|
|
|
async function ensureTelegramRow() {
|
|
let row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
|
|
if (!row) {
|
|
await db.insert(alertTelegramSettings).values({ id: 1 })
|
|
row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
|
|
}
|
|
return row!
|
|
}
|
|
|
|
async function lastFiredByRuleId(): Promise<Map<string, string>> {
|
|
const rows = await dbAll<{ ruleId: string; mx: string }>(
|
|
`SELECT rule_id AS "ruleId", MAX(fired_at) AS mx
|
|
FROM alert_history
|
|
WHERE rule_id IS NOT NULL AND rule_id != ''
|
|
GROUP BY rule_id`,
|
|
)
|
|
const m = new Map<string, string>()
|
|
for (const r of rows) {
|
|
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx)
|
|
}
|
|
return m
|
|
}
|
|
|
|
async function targetsByRuleId(): Promise<Map<string, string[]>> {
|
|
const rows = await db
|
|
.select()
|
|
.from(alertRuleTargets)
|
|
.orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id))
|
|
const m = new Map<string, string[]>()
|
|
for (const t of rows) {
|
|
const rid = t.ruleId
|
|
const arr = m.get(rid) ?? []
|
|
arr.push(t.target)
|
|
m.set(rid, arr)
|
|
}
|
|
return m
|
|
}
|
|
|
|
async function conditionsByRuleId(): Promise<Map<string, string[]>> {
|
|
const rows = await db
|
|
.select()
|
|
.from(alertRuleConditions)
|
|
.orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id))
|
|
const m = new Map<string, string[]>()
|
|
for (const t of rows) {
|
|
const rid = t.ruleId
|
|
const arr = m.get(rid) ?? []
|
|
arr.push(t.conditionLine)
|
|
m.set(rid, arr)
|
|
}
|
|
return m
|
|
}
|
|
|
|
export async function listAlertGroups(): Promise<ApiAlertGroup[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(alertGroups)
|
|
.orderBy(asc(alertGroups.name))
|
|
return rows.map((g) => ({
|
|
id: g.id,
|
|
name: g.name,
|
|
combineMode: g.combineMode,
|
|
enabled: g.enabled,
|
|
cooldownOverride: g.cooldownOverride ?? null,
|
|
}))
|
|
}
|
|
|
|
export async function listAlertRules(): Promise<ApiAlertRule[]> {
|
|
const lf = await lastFiredByRuleId()
|
|
const tgtMap = await targetsByRuleId()
|
|
const condMap = await conditionsByRuleId()
|
|
const rows = await db.select().from(alertRules)
|
|
return rows.map((r) => {
|
|
const targets = tgtMap.get(r.id)
|
|
const ts = targets?.length ? targets : [r.target]
|
|
const condLines = condMap.get(r.id)
|
|
const cs = condLines?.length ? condLines : [r.condition]
|
|
return {
|
|
id: r.id,
|
|
name: r.name,
|
|
type: r.type,
|
|
target: r.target,
|
|
targets: ts,
|
|
groupId: r.groupId ?? null,
|
|
condition: r.condition,
|
|
conditions: cs,
|
|
severity: r.severity,
|
|
enabled: r.enabled,
|
|
cooldown: r.cooldown,
|
|
confirmStabilitySec:
|
|
r.confirmStabilitySec != null && r.confirmStabilitySec > 0 ? r.confirmStabilitySec : null,
|
|
recoveryMode:
|
|
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always",
|
|
recoveryStabilitySec:
|
|
r.recoveryStabilitySec != null && r.recoveryStabilitySec > 0 ? r.recoveryStabilitySec : null,
|
|
chatId: r.chatId ?? "",
|
|
lastFiredAt: lf.get(r.id) ?? null,
|
|
}
|
|
})
|
|
}
|
|
|
|
function summarizeTargets(targets: string[]): string {
|
|
const u = targets.map((t) => t.trim()).filter(Boolean)
|
|
if (u.length === 0) return ""
|
|
if (u.length <= 2) return u.join(", ")
|
|
return `${u[0]}, ${u[1]} +${u.length - 2}`
|
|
}
|
|
|
|
function summarizeConditionLines(lines: string[]): string {
|
|
const u = lines.map((t) => t.trim()).filter(Boolean)
|
|
if (u.length === 0) return "—"
|
|
if (u.length === 1) return u[0]!
|
|
if (u.length <= 3) return u.join(" · ")
|
|
return `${u[0]} · ${u[1]} · +${u.length - 2}`
|
|
}
|
|
|
|
export type ReplaceAlertRuleInput = {
|
|
id: string
|
|
name: string
|
|
type: ApiAlertRule["type"]
|
|
/** @deprecated используйте targets; если нет targets — берётся target */
|
|
target?: string
|
|
targets?: string[]
|
|
/** Сводная строка; если не передана — из conditions */
|
|
condition?: string
|
|
/** Несколько условий (OR); если пусто — используется `condition` */
|
|
conditions?: string[]
|
|
severity: ApiAlertRule["severity"]
|
|
enabled: boolean
|
|
cooldown: ApiAlertRule["cooldown"]
|
|
/** Секунды; 0 / undefined / null — без задержки подтверждения */
|
|
confirmStabilitySec?: number | null
|
|
recoveryMode?: "always" | "never" | "conditional"
|
|
recoveryStabilitySec?: number | null
|
|
chatId: string
|
|
groupId?: string | null
|
|
}
|
|
|
|
export type ReplaceAlertGroupInput = {
|
|
id: string
|
|
name: string
|
|
combineMode: "any" | "all"
|
|
enabled: boolean
|
|
cooldownOverride: ApiAlertRule["cooldown"] | null
|
|
}
|
|
|
|
/** Атомарно заменяет группы, правила и строки targets. */
|
|
export async function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
|
|
const now = sql`now()`
|
|
const { groups, rules } = payload
|
|
await db.transaction(async (tx) => {
|
|
await tx.delete(alertRuleConditions)
|
|
await tx.delete(alertRuleTargets)
|
|
await tx.delete(alertRules)
|
|
await tx.delete(alertGroups)
|
|
for (const g of groups) {
|
|
await tx.insert(alertGroups).values({
|
|
id: g.id,
|
|
name: g.name.trim() || g.id,
|
|
combineMode: g.combineMode,
|
|
enabled: g.enabled,
|
|
cooldownOverride: g.cooldownOverride ?? null,
|
|
updatedAt: now,
|
|
})
|
|
}
|
|
for (const r of rules) {
|
|
const rawTargets =
|
|
r.targets && r.targets.length > 0
|
|
? r.targets.map((t) => t.trim()).filter(Boolean)
|
|
: [String(r.target ?? "").trim()].filter(Boolean)
|
|
const summary = summarizeTargets(rawTargets.length ? rawTargets : ["—"])
|
|
const rawConds =
|
|
r.conditions && r.conditions.length > 0
|
|
? r.conditions.map((c) => c.trim()).filter(Boolean)
|
|
: [String(r.condition ?? "").trim()].filter(Boolean)
|
|
const condSummary = summarizeConditionLines(rawConds.length ? rawConds : ["—"])
|
|
const stab =
|
|
r.confirmStabilitySec != null &&
|
|
Number.isFinite(r.confirmStabilitySec) &&
|
|
r.confirmStabilitySec > 0
|
|
? Math.min(86400, Math.max(1, Math.floor(r.confirmStabilitySec)))
|
|
: null
|
|
const recoveryMode =
|
|
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always"
|
|
const recoveryStabilitySec =
|
|
r.recoveryStabilitySec != null &&
|
|
Number.isFinite(r.recoveryStabilitySec) &&
|
|
r.recoveryStabilitySec > 0
|
|
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
|
|
: null
|
|
await tx.insert(alertRules).values({
|
|
id: r.id,
|
|
name: r.name,
|
|
type: r.type,
|
|
target: summary || "—",
|
|
condition: condSummary,
|
|
severity: r.severity,
|
|
enabled: r.enabled,
|
|
cooldown: r.cooldown,
|
|
confirmStabilitySec: stab,
|
|
recoveryMode,
|
|
recoveryStabilitySec,
|
|
chatId: r.chatId ?? "",
|
|
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
|
|
updatedAt: now,
|
|
})
|
|
for (const [i, t] of rawTargets.entries()) {
|
|
await tx.insert(alertRuleTargets).values({
|
|
id: `rt-${r.id}-${i}`,
|
|
ruleId: r.id,
|
|
target: t,
|
|
sortIndex: i,
|
|
})
|
|
}
|
|
for (const [i, c] of rawConds.entries()) {
|
|
await tx.insert(alertRuleConditions).values({
|
|
id: `rc-${r.id}-${i}`,
|
|
ruleId: r.id,
|
|
conditionLine: c,
|
|
sortIndex: i,
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Совместимость: только правила, группы не трогаем. */
|
|
export async function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
|
|
const curGroups = await listAlertGroups()
|
|
await replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
|
|
}
|
|
|
|
export async function getTelegramSettingsRow() {
|
|
return await ensureTelegramRow()
|
|
}
|
|
|
|
export async function getTelegramPublic() {
|
|
const row = await ensureTelegramRow()
|
|
const tid = row.messageThreadId
|
|
const messageThreadId =
|
|
typeof tid === "number" && Number.isFinite(tid) && Math.floor(tid) >= 1 ? Math.floor(tid) : null
|
|
return {
|
|
chatId: row.chatId ?? "",
|
|
tokenConfigured: Boolean(row.botToken?.trim()),
|
|
messageThreadId,
|
|
}
|
|
}
|
|
|
|
export async function updateTelegramSettings(patch: {
|
|
token?: string | null
|
|
chatId?: string | undefined
|
|
/** undefined — не менять; null — сбросить тему */
|
|
messageThreadId?: number | null
|
|
}) {
|
|
const cur = await ensureTelegramRow()
|
|
let nextToken = cur.botToken
|
|
let nextChat = cur.chatId ?? ""
|
|
let nextThread: number | null | undefined = undefined
|
|
|
|
if (patch.token !== undefined) {
|
|
if (patch.token === null || patch.token === "") nextToken = ""
|
|
else nextToken = patch.token.trim()
|
|
}
|
|
if (patch.chatId !== undefined) nextChat = patch.chatId.trim()
|
|
if (patch.messageThreadId !== undefined) {
|
|
if (patch.messageThreadId === null) nextThread = null
|
|
else {
|
|
const n = Math.floor(patch.messageThreadId)
|
|
nextThread = Number.isFinite(n) && n >= 1 ? n : null
|
|
}
|
|
}
|
|
|
|
const base = {
|
|
botToken: nextToken,
|
|
chatId: nextChat,
|
|
updatedAt: sql`now()`,
|
|
}
|
|
if (nextThread !== undefined) {
|
|
await db.update(alertTelegramSettings)
|
|
.set({ ...base, messageThreadId: nextThread })
|
|
.where(eq(alertTelegramSettings.id, 1))
|
|
} else {
|
|
await db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1))
|
|
}
|
|
return await getTelegramPublic()
|
|
}
|
|
|
|
/** Токен только для внутреннего вызова Telegram API (не отдаётся клиенту). */
|
|
export async function getTelegramBotToken(): Promise<string> {
|
|
return (await ensureTelegramRow()).botToken?.trim() ?? ""
|
|
}
|
|
|
|
/** Для `sendMessage`: только если в БД задана валидная тема. */
|
|
export async function getTelegramMessageThreadIdForApi(): Promise<number | undefined> {
|
|
const { messageThreadId } = await getTelegramPublic()
|
|
return messageThreadId ?? undefined
|
|
}
|
|
|
|
/** Текст тестового сообщения по полям правила (как в UI предпросмотра, без записи в историю). */
|
|
export function formatAlertRuleTestTelegramText(opts: {
|
|
name: string
|
|
targets: string[]
|
|
conditionLine: string
|
|
severity: "critical" | "warning" | "info"
|
|
cooldown: string
|
|
}): string {
|
|
const emoji =
|
|
opts.severity === "critical" ? "🔴" : opts.severity === "warning" ? "🟡" : "🔵"
|
|
const obj = opts.targets.length ? opts.targets.join(", ") : "—"
|
|
return [
|
|
"🧪 MikroTik Manager — тестовая отправка по полям формы правила (в историю срабатываний не записывается).",
|
|
"",
|
|
`${emoji} ${opts.name}`,
|
|
`Объект: ${obj}`,
|
|
`Событие: ${opts.conditionLine}`,
|
|
`Cooldown: ${opts.cooldown}`,
|
|
].join("\n")
|
|
}
|
|
|
|
/** Отправка текста в Telegram (движок алертов и тест). */
|
|
export async function sendTelegramAlertMessage(opts: {
|
|
text: string
|
|
/** Если задан — вместо токена из БД (ручной тест) */
|
|
token?: string
|
|
/** Пусто — глобальный chat из БД */
|
|
chatId?: string
|
|
/** Переопределение темы; undefined — как в настройках */
|
|
messageThreadId?: number | null
|
|
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
const token = (opts.token?.trim() || await getTelegramBotToken()).trim()
|
|
if (!token) return { ok: false, error: "Не задан Bot Token" }
|
|
const pub = await getTelegramPublic()
|
|
const chatId = (opts.chatId?.trim() || pub.chatId || "").trim()
|
|
if (!chatId) return { ok: false, error: "Не задан Chat ID" }
|
|
const threadId =
|
|
opts.messageThreadId !== undefined && opts.messageThreadId !== null
|
|
? opts.messageThreadId >= 1
|
|
? Math.floor(opts.messageThreadId)
|
|
: undefined
|
|
: await getTelegramMessageThreadIdForApi()
|
|
const url = `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`
|
|
try {
|
|
const payload: { chat_id: string; text: string; message_thread_id?: number } = {
|
|
chat_id: chatId,
|
|
text: opts.text,
|
|
}
|
|
if (threadId != null) payload.message_thread_id = threadId
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
|
|
if (!res.ok || data.ok === false) {
|
|
return { ok: false, error: data.description ?? res.statusText ?? "Telegram API error" }
|
|
}
|
|
return { ok: true }
|
|
} catch (e) {
|
|
return { ok: false, error: e instanceof Error ? e.message : "Ошибка сети" }
|
|
}
|
|
}
|
|
|
|
export async function appendAlertHistory(entry: {
|
|
id: string
|
|
ruleId?: string | null
|
|
groupId?: string | null
|
|
ruleName: string
|
|
severity: (typeof alertHistory.$inferSelect)["severity"]
|
|
message: string
|
|
sentOk: boolean
|
|
firedAt?: string
|
|
}) {
|
|
const firedAt = entry.firedAt ?? new Date().toISOString()
|
|
await db.insert(alertHistory)
|
|
.values({
|
|
id: entry.id,
|
|
ruleId: entry.ruleId ?? null,
|
|
groupId: entry.groupId ?? null,
|
|
ruleName: entry.ruleName,
|
|
severity: entry.severity,
|
|
message: entry.message,
|
|
sentOk: entry.sentOk,
|
|
firedAt,
|
|
})
|
|
}
|
|
|
|
interface LiveResRow {
|
|
serverName: string
|
|
sampledAt: string
|
|
}
|
|
|
|
interface LiveProbeRow {
|
|
probeName: string
|
|
target: string
|
|
sampledAt: string
|
|
status: string
|
|
rttMs: number | null
|
|
lossPct: number | null
|
|
}
|
|
|
|
async function loadLiveResourceIssues(): Promise<LiveResRow[]> {
|
|
return dbAll<LiveResRow>(
|
|
`SELECT s.name AS "serverName", urs.sampled_at AS "sampledAt"
|
|
FROM uptime_resource_samples urs
|
|
INNER JOIN (
|
|
SELECT DISTINCT ON (server_id) server_id, sampled_at, status
|
|
FROM uptime_resource_samples
|
|
ORDER BY server_id, sampled_at DESC
|
|
) latest ON latest.server_id = urs.server_id AND latest.sampled_at = urs.sampled_at
|
|
INNER JOIN servers s ON s.id = urs.server_id
|
|
WHERE urs.status = 'offline'`,
|
|
)
|
|
}
|
|
|
|
async function loadLiveProbeIssues(): Promise<LiveProbeRow[]> {
|
|
return dbAll<LiveProbeRow>(
|
|
`SELECT p.name AS "probeName", p.target AS target, ups.sampled_at AS "sampledAt",
|
|
ups.status AS status, ups.rtt_ms AS "rttMs", ups.loss_pct AS "lossPct"
|
|
FROM uptime_probe_samples ups
|
|
INNER JOIN (
|
|
SELECT DISTINCT ON (probe_id) probe_id, sampled_at, status, rtt_ms, loss_pct
|
|
FROM uptime_probe_samples
|
|
ORDER BY probe_id, sampled_at DESC
|
|
) latest ON latest.probe_id = ups.probe_id AND latest.sampled_at = ups.sampled_at
|
|
INNER JOIN uptime_probes p ON p.id = ups.probe_id
|
|
WHERE ups.status IN ('down', 'warn')`,
|
|
)
|
|
}
|
|
|
|
async function toLiveHistoryEntries(): Promise<ApiAlertHistoryEntry[]> {
|
|
const out: ApiAlertHistoryEntry[] = []
|
|
for (const r of await loadLiveResourceIssues()) {
|
|
const safeId = `live-res-${r.serverName}-${r.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
|
out.push({
|
|
id: safeId,
|
|
ruleName: "Сервер (ресурсы)",
|
|
severity: "critical",
|
|
message: `${r.serverName}: статус offline по данным мониторинга`,
|
|
sent: true,
|
|
firedAt: r.sampledAt,
|
|
source: "live",
|
|
})
|
|
}
|
|
for (const p of await loadLiveProbeIssues()) {
|
|
const sev = p.status === "down" ? "critical" : "warning"
|
|
const loss = p.lossPct != null ? `${p.lossPct}%` : "—"
|
|
const rtt = p.rttMs != null ? `${p.rttMs} мс` : "—"
|
|
const safeId = `live-prb-${p.probeName}-${p.target}-${p.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
|
out.push({
|
|
id: safeId,
|
|
ruleName: "Ping-проба",
|
|
severity: sev,
|
|
message: `${p.probeName} → ${p.target}: ${p.status} (RTT ${rtt}, потери ${loss})`,
|
|
sent: true,
|
|
firedAt: p.sampledAt,
|
|
source: "live",
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
export async function listPersistedHistory(limit: number): Promise<ApiAlertHistoryEntry[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(alertHistory)
|
|
.orderBy(desc(alertHistory.firedAt))
|
|
.limit(limit)
|
|
return rows.map((h) => ({
|
|
id: h.id,
|
|
ruleName: h.ruleName,
|
|
severity: h.severity,
|
|
message: h.message,
|
|
sent: h.sentOk,
|
|
firedAt: h.firedAt,
|
|
source: "db" as const,
|
|
}))
|
|
}
|
|
|
|
export async function listMergedHistory(persistLimit = 40, liveCap = 25): Promise<ApiAlertHistoryEntry[]> {
|
|
const persisted = await listPersistedHistory(persistLimit)
|
|
const live = (await toLiveHistoryEntries()).slice(0, liveCap)
|
|
const merged = [...persisted, ...live].sort((a, b) => (a.firedAt < b.firedAt ? 1 : a.firedAt > b.firedAt ? -1 : 0))
|
|
const seen = new Set<string>()
|
|
const dedup: ApiAlertHistoryEntry[] = []
|
|
for (const e of merged) {
|
|
if (seen.has(e.id)) continue
|
|
seen.add(e.id)
|
|
dedup.push(e)
|
|
if (dedup.length >= 50) break
|
|
}
|
|
return dedup
|
|
}
|
|
|
|
export async function getAlertsMeta() {
|
|
const srvRows = await db
|
|
.select({
|
|
name: servers.name,
|
|
host: servers.host,
|
|
site: servers.site,
|
|
country: servers.country,
|
|
type: servers.type,
|
|
})
|
|
.from(servers)
|
|
const labels = srvRows.map((s) => (s.name?.trim() ? s.name.trim() : s.host))
|
|
const serversDetail = srvRows.map((r) => ({
|
|
name: r.name?.trim() || "",
|
|
host: r.host?.trim() || "",
|
|
site: r.site?.trim() || "",
|
|
country: r.country?.trim() || "",
|
|
type: (r.type ?? "home-router") as string,
|
|
}))
|
|
const probes = await db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes)
|
|
const probeTargets = probes.map((p) => `${p.name} → ${p.target}`)
|
|
const defaults = ["8.8.8.8", "1.1.1.1"]
|
|
const rttLossTargets: string[] = []
|
|
for (const L of labels.slice(0, 12)) {
|
|
for (const d of defaults) rttLossTargets.push(`${L} → ${d}`)
|
|
}
|
|
return {
|
|
servers: labels,
|
|
greClients: labels,
|
|
probeTargets: probeTargets.length ? probeTargets : rttLossTargets,
|
|
rttLossTargets: rttLossTargets.length ? rttLossTargets : labels.map((L) => `${L} → 8.8.8.8`),
|
|
trafficServers: labels,
|
|
serversDetail,
|
|
}
|
|
}
|