fix(mcp): ограничить доступ к журналу и уточнить подтверждение маршрута

This commit is contained in:
Codex committed 2026-11-14 18:28:44 +07:00
1 parent cc190a381c
commit 54f3d19afb
13 files changed
+160 -31

No files matched your search

+3
View File
@@ -3,11 +3,14 @@ import type { PoolClient } from "pg"
import type { McpOperationDescriptor, McpPolicyPreviewRequest } from "@mmapp/contracts/mcp" import type { McpOperationDescriptor, McpPolicyPreviewRequest } from "@mmapp/contracts/mcp"
import type { ConnectionRow, McpPrincipal } from "./store.js" import type { ConnectionRow, McpPrincipal } from "./store.js"
import type { RosSnapshot, RosStep } from "./resources.js" import type { RosSnapshot, RosStep } from "./resources.js"
import type { RoutingConfirmationObservations } from "./routing-model.js"
export interface ActionContext { export interface ActionContext {
connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal; logsFollowMaintenance?: AbortSignal connection: ConnectionRow; principal: McpPrincipal; signal?: AbortSignal; logsFollowMaintenance?: AbortSignal
/** Process-internal persistent controller identity; never parsed from tool JSON. */ /** Process-internal persistent controller identity; never parsed from tool JSON. */
persistentRoutingProfile?: { id: string; version: number } persistentRoutingProfile?: { id: string; version: number }
/** Process-internal sink for the controller's measurement identities. */
captureRoutingConfirmation?: (observations: RoutingConfirmationObservations) => void
/** Available only during target resolution, never retained by the read/prepare handler. */ /** Available only during target resolution, never retained by the read/prepare handler. */
transactionClient?: Pick<PoolClient, "query"> transactionClient?: Pick<PoolClient, "query">
} }
+10 -4
View File
@@ -422,11 +422,17 @@ add("logs.query", "mm_logs_query", "История логов", "logs", "mm:serv
add("logs.summary", "mm_logs_summary_get", "Статистика логов", "logs", "mm:servers:read", "read", "servers", logInput, { add("logs.summary", "mm_logs_summary_get", "Статистика логов", "logs", "mm:servers:read", "read", "servers", logInput, {
read: async (input, context) => { const { settings: _settings, ...summary } = await readLogSummary(logFilters(input, context)); return summary }, read: async (input, context) => { const { settings: _settings, ...summary } = await readLogSummary(logFilters(input, context)); return summary },
}) })
for (const context of [false, true]) add(context ? "logs.context" : "logs.get", context ? "mm_logs_context_get" : "mm_logs_get", "Запись журнала", "logs", "mm:servers:read", "read", "servers", z.object({ serverId, id: z.string().regex(/^\d{1,20}$/) }).strict(), { const maxLogEntryId = "9223372036854775807"
const logEntryId = z.string().regex(/^\d{1,20}$/).refine(id => {
const digits = id.replace(/^0+/, "")
return digits.length < maxLogEntryId.length || (digits.length === maxLogEntryId.length && digits <= maxLogEntryId)
}, "ID журнала выходит за допустимый диапазон")
for (const withContext of [false, true]) add(withContext ? "logs.context" : "logs.get", withContext ? "mm_logs_context_get" : "mm_logs_get", "Запись журнала", "logs", "mm:servers:read", "read", "servers", z.object({ serverId, id: logEntryId }).strict(), {
read: async input => { read: async input => {
const entry = await readLogEntry(String(input.id)) const id = String(input.id), serverId = Number(input.serverId)
if (!entry || entry.serverId !== input.serverId) throw new McpError("NOT_FOUND", "Событие не найдено", 404) const result = await (withContext ? readLogContext(id, serverId) : readLogEntry(id, serverId))
return context ? readLogContext(String(input.id)) : entry if (!result) throw new McpError("NOT_FOUND", "Событие не найдено", 404)
return result
}, },
}) })
add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, limit: logInput.shape.limit.default(200), cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), { add("logs.follow", "mm_logs_follow", "Новые события", "logs", "mm:servers:read", "read", "servers", logInput.safeExtend({ serverId, limit: logInput.shape.limit.default(200), cursor: z.string().max(8192).optional(), waitSeconds: z.number().int().min(0).max(30).default(30) }), {
+3 -1
View File
@@ -61,7 +61,9 @@ async function analyzeCommunityProfile(profile: z.infer<typeof routeAiProfileSch
row.eligible = false; row.reasons.push("CANDIDATE_CONTEXT_UNAVAILABLE") row.eligible = false; row.reasons.push("CANDIDATE_CONTEXT_UNAVAILABLE")
} }
ranking.sort((a, b) => Number(b.eligible) - Number(a.eligible)) ranking.sort((a, b) => Number(b.eligible) - Number(a.eligible))
return { profile, ranking, ...routeContext.before, observedPath: false as const, returnPathVerified: false as const, observedAt: new Date().toISOString() } context.captureRoutingConfirmation?.(new Map(ranking.map(row => [row.candidate.id, row.confirmationObservationIds])))
const publicRanking = ranking.map(({ confirmationObservationIds: _private, ...row }) => row)
return { profile, ranking: publicRanking, ...routeContext.before, observedPath: false as const, returnPathVerified: false as const, observedAt: new Date().toISOString() }
} }
async function assertCandidateEligible(profile: z.infer<typeof routeAiProfileSchema>, candidateId: string) { async function assertCandidateEligible(profile: z.infer<typeof routeAiProfileSchema>, candidateId: string) {
@@ -142,6 +142,50 @@ test("controller does not count polling the same sample as consecutive confirmat
assert.equal(decideRoutingSwitch(profile, state, ranks, null, now).switchTo, null) assert.equal(decideRoutingSwitch(profile, state, ranks, null, now).switchTo, null)
}) })
test("controller counts only samples used for ranking or SLA and resets an old mixed streak", () => {
const slow = a.map(row => ({ ...row, pingMs: 100, bandwidthAt: null, dlMbps: null, ulMbps: null }))
const base = [...slow, sample("b1", 1, 4, "wan-b", { pingMs: 1, bandwidthAt: null, dlMbps: null, ulMbps: null }),
sample("b2", 4, 3, "exit-b", { pingMs: 1, bandwidthAt: null, dlMbps: null, ulMbps: null })]
const bandwidth = base.map(row => ({ ...row, id: `bw-${row.id}`, pingAt: null, pingMs: null, lossPct: null,
bandwidthAt: at, dlMbps: 100, ulMbps: 100 }))
const pingOnly = routeAiProfileSchema.parse({ ...profile, weights: { ping: 100, speed: 0, loss: 0 } })
const decide = (configured: typeof profile, state: ControllerState, observations: RouteObservation[]) => {
const ranks = rankRoutingCandidates(configured, observations, now + 20)
const confirmation = new Map(ranks.map(row => [row.candidate.id, row.confirmationObservationIds]))
return decideRoutingSwitch(configured, state, ranks, "a", now + 20, [], undefined, confirmation)
}
let state: ControllerState = { pendingCandidateId: "b", consecutive: 2, observationKey: "old-mixed-key" }
for (let i = 0; i < 3; i++) {
const observations = [...base, ...bandwidth, { ...bandwidth[2], id: `bw-new-${i}`, bandwidthAt: new Date(now + i + 1).toISOString() }]
const result = decide(pingOnly, state, observations)
assert.equal(result.switchTo, null)
assert.equal(result.state.consecutive, 1)
state = result.state
}
assert.equal(state.observationKey, "ping:a1:a2:b1:b2")
const fresh = (i: number) => [...base, ...bandwidth, { ...base[2], id: `ping-new-${i}`, pingAt: new Date(now + i + 1).toISOString() }]
const second = decide(pingOnly, state, fresh(1))
assert.equal(second.state.consecutive, 2)
assert.equal(decide(pingOnly, second.state, fresh(1)).state.consecutive, 2)
assert.equal(decide(pingOnly, second.state, fresh(2)).switchTo, "b")
const withBandwidthSla = routeAiProfileSchema.parse({ ...pingOnly, minBandwidthMbps: 10 })
const initial = decide(withBandwidthSla, {}, [...base, ...bandwidth])
const updated = decide(withBandwidthSla, initial.state, [...base, ...bandwidth, {
...bandwidth[2], id: "new-sla-sample", bandwidthAt: new Date(now + 1).toISOString(),
}])
assert.equal(initial.state.observationKey, "a1:a2:b1:b2:bw-a1:bw-a2:bw-b1:bw-b2")
assert.equal(updated.state.consecutive, 2)
const bandwidthOnly = routeAiProfileSchema.parse({ ...profile, weights: { ping: 0, speed: 100, loss: 0 } })
const unequalBandwidth = bandwidth.map((row, i) => ({ ...row, dlMbps: i < 2 ? 20 : 800, ulMbps: i < 2 ? 20 : 800 }))
const speedInitial = decide(bandwidthOnly, {}, [...base, ...unequalBandwidth])
const speedWithNewPing = decide(bandwidthOnly, speedInitial.state, [...base, ...unequalBandwidth, fresh(1).at(-1)!])
assert.equal(speedInitial.state.observationKey, "bandwidth:bw-a1:bw-a2:bw-b1:bw-b2")
assert.equal(speedWithNewPing.state.consecutive, 1)
assert.equal(speedWithNewPing.switchTo, null)
})
test("hysteresis retains pending confirmation without counting sub-threshold observations or releasing cooldown", () => { test("hysteresis retains pending confirmation without counting sub-threshold observations or releasing cooldown", () => {
const ranked = rankRoutingCandidates(profile, [...a, sample("b1", 1, 4, "wan-b"), sample("b2", 4, 3, "exit-b")], now) const ranked = rankRoutingCandidates(profile, [...a, sample("b1", 1, 4, "wan-b"), sample("b2", 4, 3, "exit-b")], now)
const compare = (state: ControllerState, gain: number, observation: string, time = now) => { const compare = (state: ControllerState, gain: number, observation: string, time = now) => {
+28 -5
View File
@@ -4,7 +4,14 @@ export function fresh(timestamp: string | null, seconds: number, now: number): b
const measured = timestamp === null ? NaN : Date.parse(timestamp) const measured = timestamp === null ? NaN : Date.parse(timestamp)
return Number.isFinite(measured) && measured <= now && now - measured <= seconds * 1000 return Number.isFinite(measured) && measured <= now && now - measured <= seconds * 1000
} }
function confirmationMetrics(profile: RouteAiProfile) {
return {
ping: profile.weights.ping > 0 || profile.weights.loss > 0 || profile.maxPingMs !== null || profile.maxLossPct !== null,
bandwidth: profile.weights.speed > 0 || profile.minBandwidthMbps !== null,
}
}
export function rankRoutingCandidates(profile: RouteAiProfile, observations: RouteObservation[], now = Date.now()) { export function rankRoutingCandidates(profile: RouteAiProfile, observations: RouteObservation[], now = Date.now()) {
const metrics = confirmationMetrics(profile)
function leg(src: number, dst: number, iface: string, remoteIface?: string) { function leg(src: number, dst: number, iface: string, remoteIface?: string) {
const possible = observations.flatMap(observation => { const possible = observations.flatMap(observation => {
if (observation.family !== profile.family || observation.table !== profile.table) return [] if (observation.family !== profile.family || observation.table !== profile.table) return []
@@ -22,7 +29,8 @@ export function rankRoutingCandidates(profile: RouteAiProfile, observations: Rou
const ping = latest("pingAt"), bandwidth = latest("bandwidthAt"), base = ping ?? bandwidth const ping = latest("pingAt"), bandwidth = latest("bandwidthAt"), base = ping ?? bandwidth
return base ? { ...base, pingMs: ping?.contextVerified ? ping.pingMs : null, lossPct: ping?.contextVerified ? ping.lossPct : null, pingAt: ping?.pingAt ?? null, return base ? { ...base, pingMs: ping?.contextVerified ? ping.pingMs : null, lossPct: ping?.contextVerified ? ping.lossPct : null, pingAt: ping?.pingAt ?? null,
dlMbps: bandwidth?.contextVerified ? bandwidth.dlMbps : null, ulMbps: bandwidth?.contextVerified ? bandwidth.ulMbps : null, bandwidthAt: bandwidth?.bandwidthAt ?? null, dlMbps: bandwidth?.contextVerified ? bandwidth.dlMbps : null, ulMbps: bandwidth?.contextVerified ? bandwidth.ulMbps : null, bandwidthAt: bandwidth?.bandwidthAt ?? null,
sampleIds: [...new Set([ping?.id, bandwidth?.id].filter((id): id is string => !!id))] } : undefined sampleIds: [...new Set([ping?.id, bandwidth?.id].filter((id): id is string => !!id))],
confirmationIds: [metrics.ping ? ping?.id : undefined, metrics.bandwidth ? bandwidth?.id : undefined].filter((id): id is string => !!id) } : undefined
} }
return profile.candidates.map(candidate => { return profile.candidates.map(candidate => {
const first = leg(profile.serverId, candidate.jumpHostId, candidate.wanInterface, candidate.wanRemoteInterface) const first = leg(profile.serverId, candidate.jumpHostId, candidate.wanInterface, candidate.wanRemoteInterface)
@@ -46,14 +54,18 @@ export function rankRoutingCandidates(profile: RouteAiProfile, observations: Rou
if (lossPct !== null && profile.maxLossPct !== null && lossPct > profile.maxLossPct) reasons.push("Превышены потери") if (lossPct !== null && profile.maxLossPct !== null && lossPct > profile.maxLossPct) reasons.push("Превышены потери")
if (dlMbps !== null && ulMbps !== null && profile.minBandwidthMbps !== null && Math.min(dlMbps, ulMbps) < profile.minBandwidthMbps) reasons.push("Недостаточна пропускная способность") if (dlMbps !== null && ulMbps !== null && profile.minBandwidthMbps !== null && Math.min(dlMbps, ulMbps) < profile.minBandwidthMbps) reasons.push("Недостаточна пропускная способность")
const score = reasons.length ? null : Math.round((profile.weights.ping * Math.max(0, 100 - (pingMs ?? 0) * 0.6) + profile.weights.speed * Math.min(100, ((dlMbps ?? 0) + (ulMbps ?? 0)) / 18) + profile.weights.loss * (100 - (lossPct ?? 0))) / 100) const score = reasons.length ? null : Math.round((profile.weights.ping * Math.max(0, 100 - (pingMs ?? 0) * 0.6) + profile.weights.speed * Math.min(100, ((dlMbps ?? 0) + (ulMbps ?? 0)) / 18) + profile.weights.loss * (100 - (lossPct ?? 0))) / 100)
return { candidate, eligible: !reasons.length, score, reasons, pingMs, lossPct, dlMbps, ulMbps, observationIds: legs.flatMap(value => value?.sampleIds ?? []), observedPath: false as const } return { candidate, eligible: !reasons.length, score, reasons, pingMs, lossPct, dlMbps, ulMbps, observationIds: legs.flatMap(value => value?.sampleIds ?? []),
confirmationObservationIds: legs.flatMap(value => value?.confirmationIds ?? []), observedPath: false as const }
}).sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || a.candidate.id.localeCompare(b.candidate.id)) }).sort((a, b) => (b.score ?? -1) - (a.score ?? -1) || a.candidate.id.localeCompare(b.candidate.id))
} }
export interface PathHealth { id: string; candidateId: string; status: "passed" | "failed" | "unknown"; observedAt: string } export interface PathHealth { id: string; candidateId: string; status: "passed" | "failed" | "unknown"; observedAt: string }
export interface ControllerState { pendingCandidateId?: string; consecutive?: number; observationKey?: string; lastSwitchAt?: string; switches?: string[]; operationId?: string; pausedReason?: string; failureCount?: number; failureKey?: string; failureObservedAt?: string; failingCandidateId?: string } export interface ControllerState { pendingCandidateId?: string; consecutive?: number; observationKey?: string; lastSwitchAt?: string; switches?: string[]; operationId?: string; pausedReason?: string; failureCount?: number; failureKey?: string; failureObservedAt?: string; failingCandidateId?: string }
export function selectFailoverReserveProbes(ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string, previous?: string) { export type PublicRoutingRanking = Array<Omit<ReturnType<typeof rankRoutingCandidates>[number], "confirmationObservationIds">>
export type RoutingConfirmationObservations = ReadonlyMap<string, readonly string[]>
export function selectFailoverReserveProbes(ranking: PublicRoutingRanking, currentCandidateId: string, previous?: string) {
const standbys = ranking.filter(({ candidate, eligible }) => eligible && candidate.id !== currentCandidateId && candidate.controlProbe) const standbys = ranking.filter(({ candidate, eligible }) => eligible && candidate.id !== currentCandidateId && candidate.controlProbe)
.map(({ candidate }) => candidate.id) .map(({ candidate }) => candidate.id)
const [preferred, ...remaining] = standbys const [preferred, ...remaining] = standbys
@@ -64,7 +76,7 @@ export function selectFailoverReserveProbes(ranking: ReturnType<typeof rankRouti
} }
const pathHealthFreshnessSec = 90 const pathHealthFreshnessSec = 90
export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: ReturnType<typeof rankRoutingCandidates>, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number) { export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerState, ranking: PublicRoutingRanking, currentCandidateId: string | null, now = Date.now(), health: PathHealth[] = [], chargedSwitchesPerHour?: number, confirmation?: RoutingConfirmationObservations) {
const next: ControllerState = { ...state, switches: (state.switches ?? []).filter(at => now - Date.parse(at) < 3600000) } const next: ControllerState = { ...state, switches: (state.switches ?? []).filter(at => now - Date.parse(at) < 3600000) }
if (chargedSwitchesPerHour !== undefined && (!Number.isSafeInteger(chargedSwitchesPerHour) || chargedSwitchesPerHour < next.switches!.length || chargedSwitchesPerHour > 4096)) throw Error("Invalid common routing charge count") if (chargedSwitchesPerHour !== undefined && (!Number.isSafeInteger(chargedSwitchesPerHour) || chargedSwitchesPerHour < next.switches!.length || chargedSwitchesPerHour > 4096)) throw Error("Invalid common routing charge count")
const switchCount = chargedSwitchesPerHour ?? next.switches!.length const switchCount = chargedSwitchesPerHour ?? next.switches!.length
@@ -93,7 +105,18 @@ export function decideRoutingSwitch(profile: RouteAiProfile, state: ControllerSt
const gain = best.score! - current.score const gain = best.score! - current.score
if (gain < Math.max(0, profile.switchThreshold - profile.hysteresis)) return { state: { ...next, consecutive: 0, pendingCandidateId: undefined }, switchTo: null, reason: "Сработал гистерезис" } if (gain < Math.max(0, profile.switchThreshold - profile.hysteresis)) return { state: { ...next, consecutive: 0, pendingCandidateId: undefined }, switchTo: null, reason: "Сработал гистерезис" }
if (gain < profile.switchThreshold) return { state: next, switchTo: null, reason: "Порог улучшения не достигнут" } if (gain < profile.switchThreshold) return { state: next, switchTo: null, reason: "Порог улучшения не достигнут" }
const observationKey = [...best.observationIds, ...current.observationIds].sort().join(":") const metrics = confirmationMetrics(profile)
let observationKey: string
if (metrics.ping && metrics.bandwidth) {
observationKey = [...best.observationIds, ...current.observationIds].sort().join(":")
} else {
const bestIds = confirmation?.get(best.candidate.id), currentIds = confirmation?.get(current.candidate.id)
if (!bestIds?.length || !currentIds?.length) return { state: next, switchTo: null, reason: "Нет подтверждённых измерений для выбора" }
const prefix = metrics.ping ? "ping:" : "bandwidth:"
observationKey = prefix + [...new Set([...bestIds, ...currentIds])].sort().join(":")
// Old streaks may include samples irrelevant to the current policy.
if (!next.observationKey?.startsWith(prefix)) next.consecutive = 0
}
if (next.pendingCandidateId !== undefined && next.pendingCandidateId !== best.candidate.id) { if (next.pendingCandidateId !== undefined && next.pendingCandidateId !== best.candidate.id) {
next.pendingCandidateId = best.candidate.id next.pendingCandidateId = best.candidate.id
next.consecutive = 0 next.consecutive = 0
+6 -3
View File
@@ -9,7 +9,7 @@ import { pool } from "../../db/index.js"
import { registerMcpService } from "./service-registry.js" import { registerMcpService } from "./service-registry.js"
import { accessGeneration, McpError } from "./security.js" import { accessGeneration, McpError } from "./security.js"
import { audit, validatePrincipal, type McpPrincipal } from "./store.js" import { audit, validatePrincipal, type McpPrincipal } from "./store.js"
import { decideRoutingSwitch, selectFailoverReserveProbes, type ControllerState, type PathHealth, type rankRoutingCandidates } from "./routing-model.js" import { decideRoutingSwitch, selectFailoverReserveProbes, type ControllerState, type PathHealth, type PublicRoutingRanking, type RoutingConfirmationObservations } from "./routing-model.js"
import type { add } from "./actions.js" import type { add } from "./actions.js"
import type { ActionContext } from "./action-types.js" import type { ActionContext } from "./action-types.js"
import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js" import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js"
@@ -124,7 +124,10 @@ export async function tickRoutingProfiles() {
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection}) await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
if (state.probedAt && Date.now() - Date.parse(state.probedAt) < 55000) return if (state.probedAt && Date.now() - Date.parse(state.probedAt) < 55000) return
await current() await current()
const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile, limit: 100 }) as { ranking: ReturnType<typeof rankRoutingCandidates>; currentCandidateId: string | null } let confirmation: RoutingConfirmationObservations | undefined
const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile, limit: 100 }, undefined, {
captureRoutingConfirmation: observations => { confirmation = observations },
}) as { ranking: PublicRoutingRanking; currentCandidateId: string | null }
if (state.expectedCandidateId && analysis.currentCandidateId !== state.expectedCandidateId) { await stop("Наблюдаемый путь изменился вне ожидаемого переключения; требуется проверка вмешательства или дрейфа"); return } if (state.expectedCandidateId && analysis.currentCandidateId !== state.expectedCandidateId) { await stop("Наблюдаемый путь изменился вне ожидаемого переключения; требуется проверка вмешательства или дрейфа"); return }
if (analysis.currentCandidateId) next.expectedCandidateId = analysis.currentCandidateId if (analysis.currentCandidateId) next.expectedCandidateId = analysis.currentCandidateId
if (profile.candidates.some(candidate => candidate.controlProbe) && !connection.policy.scope.operationIds.includes("routing.path.verify")) { await stop("Не выдано право контрольной проверки пути"); return } if (profile.candidates.some(candidate => candidate.controlProbe) && !connection.policy.scope.operationIds.includes("routing.path.verify")) { await stop("Не выдано право контрольной проверки пути"); return }
@@ -152,7 +155,7 @@ export async function tickRoutingProfiles() {
const age = decisionAt - Date.parse(probe.observedAt) const age = decisionAt - Date.parse(probe.observedAt)
return age >= 0 && age < COMMUNITY_PATH_MAX_AGE_MS return age >= 0 && age < COMMUNITY_PATH_MAX_AGE_MS
}) })
const decision = decideRoutingSwitch(profile, state, analysis.ranking, analysis.currentCandidateId, decisionAt, health, charges.totalCharges) const decision = decideRoutingSwitch(profile, state, analysis.ranking, analysis.currentCandidateId, decisionAt, health, charges.totalCharges, confirmation)
Object.assign(next, decision.state, { reason: decision.reason }) Object.assign(next, decision.state, { reason: decision.reason })
if (!failoverProbes?.cursor) delete next.failoverReserveCursor if (!failoverProbes?.cursor) delete next.failoverReserveCursor
else if (!hasFreshPassedProbe(failoverProbes.cursor)) { else if (!hasFreshPassedProbe(failoverProbes.cursor)) {
+12 -3
View File
@@ -125,7 +125,10 @@ async function completeRequest(id: string, started: number, result: unknown, err
await assertDeliveryAccess?.(client) await assertDeliveryAccess?.(client)
}) })
} }
export async function invokeMcpTool(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }): Promise<unknown> { type InternalMcpCall = { persistentRoutingProfile: NonNullable<ActionContext["persistentRoutingProfile"]> }
| { captureRoutingConfirmation: NonNullable<ActionContext["captureRoutingConfirmation"]> }
export async function invokeMcpTool(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: InternalMcpCall): Promise<unknown> {
if (toolName === "mm_logs_follow") { if (toolName === "mm_logs_follow") {
const maintenance = new AbortController() const maintenance = new AbortController()
return sharedDatabaseGate.request( return sharedDatabaseGate.request(
@@ -135,19 +138,24 @@ export async function invokeMcpTool(principal: McpPrincipal, toolName: string, r
} }
return sharedDatabaseGate.request(() => invokeMcpToolInner(principal, toolName, raw, signal, internal)) return sharedDatabaseGate.request(() => invokeMcpToolInner(principal, toolName, raw, signal, internal))
} }
async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: { persistentRoutingProfile: { id: string; version: number } }, logsFollowMaintenance?: AbortSignal): Promise<unknown> { async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw: unknown, signal?: AbortSignal, internal?: InternalMcpCall, logsFollowMaintenance?: AbortSignal): Promise<unknown> {
const action = findAction(toolName), input = action.schema.parse(raw) as Record<string, unknown>, started = Date.now() const action = findAction(toolName), input = action.schema.parse(raw) as Record<string, unknown>, started = Date.now()
const call = await reserveCall(principal, toolName, input, signal) const call = await reserveCall(principal, toolName, input, signal)
const cancelled = () => logsFollowMaintenance && logsFollowCancellation(logsFollowMaintenance, signal) const cancelled = () => logsFollowMaintenance && logsFollowCancellation(logsFollowMaintenance, signal)
const assertActive = () => { const failure = cancelled(); if (failure) throw failure } const assertActive = () => { const failure = cancelled(); if (failure) throw failure }
let vrfPreparing = false let vrfPreparing = false
let routingConfirmation: Parameters<NonNullable<ActionContext["captureRoutingConfirmation"]>>[0] | undefined
try { try {
assertActive() assertActive()
if (logsFollowMaintenance) call.context.logsFollowMaintenance = logsFollowMaintenance if (logsFollowMaintenance) call.context.logsFollowMaintenance = logsFollowMaintenance
if (internal) { if (internal && "persistentRoutingProfile" in internal) {
if (action.operation.id !== "routing.community.switch") throw new McpError("INVALID_INPUT", "Контекст профиля не относится к этой операции", 400) if (action.operation.id !== "routing.community.switch") throw new McpError("INVALID_INPUT", "Контекст профиля не относится к этой операции", 400)
call.context.persistentRoutingProfile = z.object({ id: z.string().uuid(), version: z.number().int().positive().safe() }).strict().parse(internal.persistentRoutingProfile) call.context.persistentRoutingProfile = z.object({ id: z.string().uuid(), version: z.number().int().positive().safe() }).strict().parse(internal.persistentRoutingProfile)
} }
if (internal && "captureRoutingConfirmation" in internal) {
if (action.operation.id !== "routing.analyze") throw new McpError("INVALID_INPUT", "Измерения контроллера не относятся к этой операции", 400)
call.context.captureRoutingConfirmation = observations => { routingConfirmation = observations }
}
let result: unknown let result: unknown
if (action.read) result = await action.read(input, call.context) if (action.read) result = await action.read(input, call.context)
else if (action.prepare) { else if (action.prepare) {
@@ -213,6 +221,7 @@ async function invokeMcpToolInner(principal: McpPrincipal, toolName: string, raw
await completeRequest(call.requestId, started, output, undefined, checkDelivery) await completeRequest(call.requestId, started, output, undefined, checkDelivery)
await checkDelivery?.() await checkDelivery?.()
assertActive() assertActive()
if (routingConfirmation && internal && "captureRoutingConfirmation" in internal) internal.captureRoutingConfirmation(routingConfirmation)
return output return output
} catch (error) { } catch (error) {
if (vrfPreparing) { if (vrfPreparing) {
+11 -3
View File
@@ -31,11 +31,19 @@ export function parseLogTime(raw: string): string | null {
const ms = Date.parse(raw.replace(" ", "T")) const ms = Date.parse(raw.replace(" ", "T"))
return Number.isFinite(ms) ? new Date(ms).toISOString() : null return Number.isFinite(ms) ? new Date(ms).toISOString() : null
} }
export function logWhere(filters: LogFilters & { allowedServerIds?: number[] }, extra: string[] = [], initial: unknown[] = []) { type LogServerScope = Pick<LogFilters, "serverId"> & { allowedServerIds?: number[] }
export function logServerConditions({ allowedServerIds, serverId }: LogServerScope, bind: (value: unknown) => string) {
const clauses: string[] = []
if (allowedServerIds !== undefined) clauses.push(`server_id = ANY(${bind(allowedServerIds)}::bigint[])`)
if (serverId) clauses.push(`server_id = ${bind(serverId)}`)
return clauses
}
export function logWhere(filters: LogFilters & LogServerScope, extra: string[] = [], initial: unknown[] = []) {
const values = [...initial], clauses = [...extra] const values = [...initial], clauses = [...extra]
const bind = (value: unknown) => { values.push(value); return `$${values.length}` } const bind = (value: unknown) => { values.push(value); return `$${values.length}` }
if (filters.allowedServerIds !== undefined) clauses.push(`server_id = ANY(${bind(filters.allowedServerIds)}::bigint[])`) clauses.push(...logServerConditions(filters, bind))
if (filters.serverId) clauses.push(`server_id = ${bind(filters.serverId)}`)
if (filters.from) clauses.push(`effective_at >= ${bind(filters.from)}::timestamptz`) if (filters.from) clauses.push(`effective_at >= ${bind(filters.from)}::timestamptz`)
if (filters.to) clauses.push(`effective_at <= ${bind(filters.to)}::timestamptz`) if (filters.to) clauses.push(`effective_at <= ${bind(filters.to)}::timestamptz`)
if (filters.levels) clauses.push(`level = ANY(${bind(filters.levels.split(","))}::text[])`) if (filters.levels) clauses.push(`level = ANY(${bind(filters.levels.split(","))}::text[])`)
+12 -8
View File
@@ -2,7 +2,7 @@ import type { PoolClient } from "pg"
import { logLevels, type LogEntry, type LogFilters } from "@mmapp/contracts" import { logLevels, type LogEntry, type LogFilters } from "@mmapp/contracts"
import { pool } from "../db/index.js" import { pool } from "../db/index.js"
import { getLogSettings, getLogStates } from "./router-logs-collector.js" import { getLogSettings, getLogStates } from "./router-logs-collector.js"
import { logWhere } from "./router-logs-model.js" import { logServerConditions, logWhere } from "./router-logs-model.js"
export const logColumns = `id::text, server_id AS "serverId",server_name AS "serverName",host,router_id AS "routerId",raw_time AS "rawTime",event_at AS "eventAt",received_at AS "receivedAt",effective_at AS "effectiveAt",CASE WHEN event_at IS NULL THEN 'receipt' ELSE 'router' END AS "timeKind",topics,level,message,generation` export const logColumns = `id::text, server_id AS "serverId",server_name AS "serverName",host,router_id AS "routerId",raw_time AS "rawTime",event_at AS "eventAt",received_at AS "receivedAt",effective_at AS "effectiveAt",CASE WHEN event_at IS NULL THEN 'receipt' ELSE 'router' END AS "timeKind",topics,level,message,generation`
export function encodeLogCursor(row: LogEntry) { return Buffer.from(JSON.stringify([row.effectiveAt, row.id])).toString("base64url") } export function encodeLogCursor(row: LogEntry) { return Buffer.from(JSON.stringify([row.effectiveAt, row.id])).toString("base64url") }
@@ -13,15 +13,18 @@ export function decodeLogCursor(cursor: string): [string, string] {
} catch { /* invalid cursor */ } } catch { /* invalid cursor */ }
throw Object.assign(new Error("Некорректный курсор журнала"), { statusCode: 400 }) throw Object.assign(new Error("Некорректный курсор журнала"), { statusCode: 400 })
} }
export async function logWatermark(client: PoolClient, serverId?: number) { export async function logWatermark(client: PoolClient, serverId?: number, allowedServerIds?: number[]) {
const res = await client.query<{ id: string }>(`SELECT COALESCE(max(id),0)::text AS id FROM router_log_entries${serverId ? " WHERE server_id=$1" : ""}`, serverId ? [serverId] : []) const values: unknown[] = []
const clauses = logServerConditions({ serverId, allowedServerIds }, value => { values.push(value); return `$${values.length}` })
const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""
const res = await client.query<{ id: string }>(`SELECT COALESCE(max(id),0)::text AS id FROM router_log_entries${where}`, values)
return res.rows[0].id return res.rows[0].id
} }
export async function readLogPage(filters: LogFilters & { allowedServerIds?: number[] }, options: { cursor?: string; watermark?: string; limit: number }) { export async function readLogPage(filters: LogFilters & { allowedServerIds?: number[] }, options: { cursor?: string; watermark?: string; limit: number }) {
const client = await pool.connect() const client = await pool.connect()
try { try {
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY") await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
const watermark = options.watermark ?? await logWatermark(client, filters.serverId) const watermark = options.watermark ?? await logWatermark(client, filters.serverId, filters.allowedServerIds)
const conditions = ["id <= $1::bigint"], values: unknown[] = [watermark] const conditions = ["id <= $1::bigint"], values: unknown[] = [watermark]
if (options.cursor) { const [at, id] = decodeLogCursor(options.cursor); conditions.push("(effective_at,id) < ($2::timestamptz,$3::bigint)"); values.push(at, id) } if (options.cursor) { const [at, id] = decodeLogCursor(options.cursor); conditions.push("(effective_at,id) < ($2::timestamptz,$3::bigint)"); values.push(at, id) }
const where = logWhere(filters, conditions, values) const where = logWhere(filters, conditions, values)
@@ -48,11 +51,12 @@ export async function readLogSummary(filters: LogFilters & { allowedServerIds?:
} }
return { total: Object.values(levels).reduce((a, b) => a + b, 0), levels, buckets: [...buckets.values()], settings: await getLogSettings(), states: await getLogStates(filters.serverId, filters.allowedServerIds) } return { total: Object.values(levels).reduce((a, b) => a + b, 0), levels, buckets: [...buckets.values()], settings: await getLogSettings(), states: await getLogStates(filters.serverId, filters.allowedServerIds) }
} }
export async function readLogEntry(id: string) { export async function readLogEntry(id: string, serverId?: number) {
return (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE id=$1`, [id])).rows[0] ?? null const scoped = serverId !== undefined
return (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE id=$1${scoped ? " AND server_id=$2" : ""}`, scoped ? [id, serverId] : [id])).rows[0] ?? null
} }
export async function readLogContext(id: string) { export async function readLogContext(id: string, serverId?: number) {
const entry = await readLogEntry(id) const entry = await readLogEntry(id, serverId)
if (!entry) return null if (!entry) return null
const before = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)<($2::timestamptz,$3::bigint) ORDER BY effective_at DESC,router_log_entries.id DESC LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows const before = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)<($2::timestamptz,$3::bigint) ORDER BY effective_at DESC,router_log_entries.id DESC LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
const after = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)>($2::timestamptz,$3::bigint) ORDER BY effective_at,router_log_entries.id LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows const after = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)>($2::timestamptz,$3::bigint) ORDER BY effective_at,router_log_entries.id LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
+20 -1
View File
@@ -8,7 +8,7 @@ import { logEntrySchema, logFiltersSchema, logPageSchema, logSummarySchema } fro
import { pool } from "../db/index.js" import { pool } from "../db/index.js"
import { env } from "../config.js" import { env } from "../config.js"
import { collectRouterLogsOnce, getLogStates, subscribeLogDevice, updateLogSettings } from "./router-logs-collector.js" import { collectRouterLogsOnce, getLogStates, subscribeLogDevice, updateLogSettings } from "./router-logs-collector.js"
import { readLogContext, readLogPage, readLogStream, readLogSummary } from "./router-logs-query.js" import { readLogContext, readLogEntry, readLogPage, readLogStream, readLogSummary } from "./router-logs-query.js"
const schema = "test_router_logs_" + randomBytes(6).toString("hex") const schema = "test_router_logs_" + randomBytes(6).toString("hex")
const admin = new pg.Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 3000 }) const admin = new pg.Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 3000 })
@@ -50,6 +50,25 @@ try {
const older = await readLogPage(filter, { limit: 200, cursor: page.nextCursor!, watermark: page.watermark }) const older = await readLogPage(filter, { limit: 200, cursor: page.nextCursor!, watermark: page.watermark })
assert.equal(older.entries.length, 101) assert.equal(older.entries.length, 101)
assert.equal(new Set([...older.entries, ...page.entries].map(e => e.id)).size, 301) assert.equal(new Set([...older.entries, ...page.entries].map(e => e.id)).size, 301)
const allServers = logFiltersSchema.parse({})
const scoped = { ...allServers, allowedServerIds: [serverId] }
const scopedFirst = await readLogPage(scoped, { limit: 200 })
const hiddenServerId = serverId + 1
const { rows: [hidden] } = await pool.query<{ id: string }>(`INSERT INTO router_log_entries
(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
VALUES($1,'hidden','192.0.2.2','*1','12:00:00',now(),now(),ARRAY['system'],'info','hidden event',0) RETURNING id::text`, [hiddenServerId])
assert.equal((await readLogPage(allServers, { limit: 200 })).watermark, hidden.id, "Unscoped history keeps its global watermark")
assert.deepEqual(await readLogPage(scoped, { limit: 200 }), scopedFirst, "Hidden writes cannot advance a scoped watermark")
assert.deepEqual(await readLogPage({ ...scoped, serverId }, { limit: 200 }), scopedFirst)
const emptyPage = { entries: [], nextCursor: null, watermark: "0" }
assert.deepEqual(await readLogPage({ ...scoped, serverId: hiddenServerId }, { limit: 200 }), emptyPage)
assert.deepEqual(await readLogPage({ ...scoped, allowedServerIds: [] }, { limit: 200 }), emptyPage)
assert.deepEqual(await readLogPage(scoped, { limit: 200, cursor: scopedFirst.nextCursor!, watermark: scopedFirst.watermark }), older)
assert.equal((await readLogEntry(hidden.id))?.serverId, hiddenServerId)
assert.equal(await readLogEntry(hidden.id, serverId), null)
assert.equal(await readLogContext(hidden.id, serverId), null)
assert.equal((await readLogContext(hidden.id, hiddenServerId))?.entry.serverId, hiddenServerId)
await pool.query("DELETE FROM router_log_entries WHERE id=$1", [hidden.id])
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'") await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
await collectRouterLogsOnce() await collectRouterLogsOnce()
assert.equal((await readLogSummary(filter)).total, 301, "Repeated snapshot is not reimported") assert.equal((await readLogSummary(filter)).total, 301, "Repeated snapshot is not reimported")
+3
View File
@@ -128,6 +128,9 @@ function LogExplorer({ base, raw, selectedName, liveAvailable }: { base: string;
</div></div> </div></div>
{range === "custom" && <div className="flex flex-wrap gap-3">{["from", "to"].map(key => <label key={key} className="flex min-w-0 flex-col gap-1 text-xs">{key === "from" ? "Начало (UTC)" : "Конец (UTC)"}<Input type="datetime-local" aria-label={key === "from" ? "Начало периода UTC" : "Конец периода UTC"} value={params.get(key)?.slice(0, 16) ?? ""} onChange={event => { if (event.target.value) navigate({ [key]: new Date(event.target.value + "Z").toISOString() }) }} /></label>)}</div>} {range === "custom" && <div className="flex flex-wrap gap-3">{["from", "to"].map(key => <label key={key} className="flex min-w-0 flex-col gap-1 text-xs">{key === "from" ? "Начало (UTC)" : "Конец (UTC)"}<Input type="datetime-local" aria-label={key === "from" ? "Начало периода UTC" : "Конец периода UTC"} value={params.get(key)?.slice(0, 16) ?? ""} onChange={event => { if (event.target.value) navigate({ [key]: new Date(event.target.value + "Z").toISOString() }) }} /></label>)}</div>}
<div role="status" className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><Badge variant={!collectorEnabled ? "secondary" : result.streamError || state?.lastError ? "warning-light" : live && result.stream ? "success-light" : "info-light"}>{collectorEnabled === undefined ? "Проверяем сборщик" : !collectorEnabled ? "Сборщик выключен" : result.streamError ? "Переподключение" : state?.lastError ? "Ошибка сбора" : live && result.stream ? "Live подключён" : "История"}</Badge><span>Последний сбор: {formatLogTime(state?.lastCollectedAt ?? result.summary?.states.map(s => s.lastCollectedAt).filter((s): s is string => !!s).sort().at(-1) ?? null)}</span>{live && <Button size="sm" variant="outline" onClick={() => { if (paused) setPaused(false); else setPaused(true) }}>{paused ? <PlayIcon /> : <PauseIcon />}{paused ? `Новых событий: ${result.pendingCount}` : "Пауза"}</Button>}</div> <div role="status" className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><Badge variant={!collectorEnabled ? "secondary" : result.streamError || state?.lastError ? "warning-light" : live && result.stream ? "success-light" : "info-light"}>{collectorEnabled === undefined ? "Проверяем сборщик" : !collectorEnabled ? "Сборщик выключен" : result.streamError ? "Переподключение" : state?.lastError ? "Ошибка сбора" : live && result.stream ? "Live подключён" : "История"}</Badge><span>Последний сбор: {formatLogTime(state?.lastCollectedAt ?? result.summary?.states.map(s => s.lastCollectedAt).filter((s): s is string => !!s).sort().at(-1) ?? null)}</span>{live && <Button size="sm" variant="outline" onClick={() => { if (paused) setPaused(false); else setPaused(true) }}>{paused ? <PlayIcon /> : <PauseIcon />}{paused ? `Новых событий: ${result.pendingCount}` : "Пауза"}</Button>}</div>
{live && collectorEnabled === true && result.streamReceivedAt !== null && <p aria-label="Получение live-логов" className="text-xs text-muted-foreground">
Получено (live-поток): <time dateTime={new Date(result.streamReceivedAt).toISOString()}>{formatLogTime(new Date(result.streamReceivedAt).toISOString())}</time>
</p>}
{collectorEnabled === false && <Alert variant="info"><AlertTitle>История доступна, сбор новых событий выключен</AlertTitle><AlertDescription>Администратор может включить коллектор в разделе <Link className="underline" href="/data-collection#router-logs">«Сбор данных»</Link>.</AlertDescription></Alert>} {collectorEnabled === false && <Alert variant="info"><AlertTitle>История доступна, сбор новых событий выключен</AlertTitle><AlertDescription>Администратор может включить коллектор в разделе <Link className="underline" href="/data-collection#router-logs">«Сбор данных»</Link>.</AlertDescription></Alert>}
{(result.streamError || state?.lastError) && <Alert variant="warning"><AlertTitle>Live требует внимания</AlertTitle><AlertDescription>{state?.lastError ?? result.streamError}</AlertDescription></Alert>} {(result.streamError || state?.lastError) && <Alert variant="warning"><AlertTitle>Live требует внимания</AlertTitle><AlertDescription>{state?.lastError ?? result.streamError}</AlertDescription></Alert>}
{state?.gapAt && <Alert variant="warning"><AlertTitle>Возможен разрыв истории</AlertTitle><AlertDescription>Буфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.</AlertDescription></Alert>} {state?.gapAt && <Alert variant="warning"><AlertTitle>Возможен разрыв истории</AlertTitle><AlertDescription>Буфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.</AlertDescription></Alert>}
+4 -3
View File
@@ -10,7 +10,8 @@ export function mergeLogEntries(previous: LogEntry[], incoming: LogEntry[], limi
const message = (error: unknown) => error instanceof Error ? error.message : String(error) const message = (error: unknown) => error instanceof Error ? error.message : String(error)
export function useRouterLogs(base: string, query: string, enabled: boolean, live: boolean, paused: boolean, windowMs = 0) { export function useRouterLogs(base: string, query: string, enabled: boolean, live: boolean, paused: boolean, windowMs = 0) {
const [entries, setEntries] = useState<LogEntry[]>([]), [page, setPage] = useState<LogPage | null>(null) const [entries, setEntries] = useState<LogEntry[]>([]), [page, setPage] = useState<LogPage | null>(null)
const [summary, setSummary] = useState<LogSummary | null>(null), [stream, setStream] = useState<LogStream | null>(null) const [summary, setSummary] = useState<LogSummary | null>(null)
const [stream, setStream] = useState<{ batch: LogStream; receivedAt: number } | null>(null)
const [loading, setLoading] = useState(enabled), [fetchingMore, setFetchingMore] = useState(false) const [loading, setLoading] = useState(enabled), [fetchingMore, setFetchingMore] = useState(false)
const [error, setError] = useState<string | null>(null), [summaryError, setSummaryError] = useState<string | null>(null), [streamError, setStreamError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null), [summaryError, setSummaryError] = useState<string | null>(null), [streamError, setStreamError] = useState<string | null>(null)
const [revision, setRevision] = useState(0), [pendingCount, setPendingCount] = useState(0) const [revision, setRevision] = useState(0), [pendingCount, setPendingCount] = useState(0)
@@ -79,7 +80,7 @@ export function useRouterLogs(base: string, query: string, enabled: boolean, liv
if (batch.entries.some(row => String(row.serverId) !== expectedServer) || batch.state && String(batch.state.serverId) !== expectedServer || BigInt(batch.cursor) < BigInt(cursor)) throw new Error("Live-поток относится к другому устройству или содержит некорректный курсор") if (batch.entries.some(row => String(row.serverId) !== expectedServer) || batch.state && String(batch.state.serverId) !== expectedServer || BigInt(batch.cursor) < BigInt(cursor)) throw new Error("Live-поток относится к другому устройству или содержит некорректный курсор")
heartbeat(); retry = 1000; cursor = batch.cursor heartbeat(); retry = 1000; cursor = batch.cursor
if (ac.signal.aborted) return if (ac.signal.aborted) return
setStream(batch); setStreamError(null) setStream({ batch, receivedAt: Date.now() }); setStreamError(null)
if (batch.entries.length) { if (batch.entries.length) {
if (pausedRef.current) { if (pausedRef.current) {
pending.current = mergeLogEntries(pending.current, batch.entries) pending.current = mergeLogEntries(pending.current, batch.entries)
@@ -119,5 +120,5 @@ export function useRouterLogs(base: string, query: string, enabled: boolean, liv
finally { moreBusy.current = false; if (!signal?.aborted) setFetchingMore(false) } finally { moreBusy.current = false; if (!signal?.aborted) setFetchingMore(false) }
}, [base, query, enabled, page]) }, [base, query, enabled, page])
const reload = useCallback(() => { pending.current = []; setPendingCount(0); setRevision(n => n + 1) }, []) const reload = useCallback(() => { pending.current = []; setPendingCount(0); setRevision(n => n + 1) }, [])
return { entries, summary, stream, loading, fetchingMore, error, summaryError, streamError, pendingCount, hasMore: !!page?.nextCursor && entries.length < 5000, loadMore, reload, flush } return { entries, summary, stream: stream?.batch ?? null, streamReceivedAt: stream?.receivedAt ?? null, loading, fetchingMore, error, summaryError, streamError, pendingCount, hasMore: !!page?.nextCursor && entries.length < 5000, loadMore, reload, flush }
} }
+4
View File
@@ -76,9 +76,13 @@ test("Логи: пауза буферизует новые события", asyn
}) })
await page.goto("/logs?server=1") await page.goto("/logs?server=1")
await expect.poll(() => started).toBe(true) await expect.poll(() => started).toBe(true)
const receipt = page.getByLabel("Получение live-логов", { exact: true }).locator("time")
await expect(receipt).toHaveCount(0)
await page.getByRole("button", { name: "Пауза", exact: true }).click() await page.getByRole("button", { name: "Пауза", exact: true }).click()
const releasedAt = Date.now()
release() release()
await expect(page.getByRole("button", { name: "Новых событий: 1", exact: true })).toBeVisible() await expect(page.getByRole("button", { name: "Новых событий: 1", exact: true })).toBeVisible()
await expect.poll(async () => Date.parse(await receipt.getAttribute("datetime", { timeout: 1000 }) ?? "")).toBeGreaterThanOrEqual(releasedAt)
await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toHaveCount(0) await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Новых событий: 1", exact: true }).click() await page.getByRole("button", { name: "Новых событий: 1", exact: true }).click()
await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toBeVisible() await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toBeVisible()