|
| 1 | +import type { ConnectionTroubleEvent } from "@t3tools/contracts"; |
| 2 | +import * as DateTime from "effect/DateTime"; |
| 3 | + |
| 4 | +import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; |
| 5 | + |
| 6 | +export const CONNECTION_LOG_LIMIT = 50; |
| 7 | +const DETAIL_LIMIT = 160; |
| 8 | +const TROUBLE_REPORT_LIMIT = 10; |
| 9 | + |
| 10 | +export interface ConnectionLogEvent { |
| 11 | + readonly at: number; |
| 12 | + readonly kind: "attempt" | "connected" | "failed" | "lost" | "waiting"; |
| 13 | + readonly attempt: number; |
| 14 | + readonly route: string | null; |
| 15 | + readonly stage: SupervisorConnectionState["stage"]; |
| 16 | + readonly reason: string | null; |
| 17 | + readonly detail: string | null; |
| 18 | + readonly network: NetworkStatus; |
| 19 | + readonly foreground: boolean | null; |
| 20 | +} |
| 21 | + |
| 22 | +/** Names the transition worth keeping, or null for progress within the same attempt. */ |
| 23 | +export function connectionLogKind( |
| 24 | + previous: SupervisorConnectionState, |
| 25 | + next: SupervisorConnectionState, |
| 26 | +): ConnectionLogEvent["kind"] | null { |
| 27 | + if (next.phase === previous.phase && next.attempt === previous.attempt) return null; |
| 28 | + switch (next.phase) { |
| 29 | + case "connecting": |
| 30 | + return previous.phase === "connecting" && next.attempt === previous.attempt |
| 31 | + ? null |
| 32 | + : "attempt"; |
| 33 | + case "connected": |
| 34 | + return "connected"; |
| 35 | + case "backoff": |
| 36 | + case "blocked": |
| 37 | + return previous.phase === "connected" ? "lost" : "failed"; |
| 38 | + case "offline": |
| 39 | + return "waiting"; |
| 40 | + case "available": |
| 41 | + return null; |
| 42 | + } |
| 43 | +} |
| 44 | + |
| 45 | +export function appendConnectionLog( |
| 46 | + log: ReadonlyArray<ConnectionLogEvent>, |
| 47 | + event: ConnectionLogEvent, |
| 48 | +): ReadonlyArray<ConnectionLogEvent> { |
| 49 | + const next = [...log, { ...event, detail: truncate(event.detail) }]; |
| 50 | + return next.length > CONNECTION_LOG_LIMIT ? next.slice(-CONNECTION_LOG_LIMIT) : next; |
| 51 | +} |
| 52 | + |
| 53 | +/** Failures and losses after `since`, newest last, small enough to ride an activity report. */ |
| 54 | +export function connectionTroubleSince( |
| 55 | + log: ReadonlyArray<ConnectionLogEvent>, |
| 56 | + since: number, |
| 57 | +): ReadonlyArray<ConnectionTroubleEvent> { |
| 58 | + return log |
| 59 | + .filter((event) => event.at > since && (event.kind === "failed" || event.kind === "lost")) |
| 60 | + .slice(-TROUBLE_REPORT_LIMIT) |
| 61 | + .map((event) => ({ |
| 62 | + at: event.at, |
| 63 | + kind: event.kind as ConnectionTroubleEvent["kind"], |
| 64 | + attempt: event.attempt, |
| 65 | + ...(event.route === null ? {} : { route: event.route }), |
| 66 | + ...(event.reason === null ? {} : { reason: event.reason }), |
| 67 | + ...(event.detail === null ? {} : { detail: event.detail }), |
| 68 | + network: event.network, |
| 69 | + ...(event.foreground === null ? {} : { foreground: event.foreground }), |
| 70 | + })); |
| 71 | +} |
| 72 | + |
| 73 | +export function formatConnectionLog(log: ReadonlyArray<ConnectionLogEvent>): string { |
| 74 | + return log |
| 75 | + .map((event) => |
| 76 | + [ |
| 77 | + DateTime.formatIso(DateTime.makeUnsafe(event.at)), |
| 78 | + event.kind, |
| 79 | + `#${event.attempt}`, |
| 80 | + event.route, |
| 81 | + event.stage, |
| 82 | + event.reason, |
| 83 | + event.detail, |
| 84 | + `net=${event.network}`, |
| 85 | + event.foreground === null ? null : event.foreground ? "fg" : "bg", |
| 86 | + ] |
| 87 | + .filter((part) => part !== null) |
| 88 | + .join(" "), |
| 89 | + ) |
| 90 | + .join("\n"); |
| 91 | +} |
| 92 | + |
| 93 | +function truncate(value: string | null): string | null { |
| 94 | + return value === null || value.length <= DETAIL_LIMIT |
| 95 | + ? value |
| 96 | + : `${value.slice(0, DETAIL_LIMIT - 1)}…`; |
| 97 | +} |
0 commit comments