mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
529 lines
15 KiB
TypeScript
529 lines
15 KiB
TypeScript
export const PACKET_TYPE_KEYS = [
|
|
"pt_00",
|
|
"pt_01",
|
|
"pt_02",
|
|
"pt_03",
|
|
"pt_04",
|
|
"pt_05",
|
|
"pt_06",
|
|
"pt_07",
|
|
"pt_08",
|
|
"pt_09",
|
|
"pt_0a",
|
|
"pt_0b",
|
|
"pt_0c",
|
|
"pt_0d",
|
|
"pt_0e",
|
|
"pt_0f",
|
|
] as const;
|
|
|
|
export const PATH_MODE_KEYS = [
|
|
"path_mode_1b",
|
|
"path_mode_2b",
|
|
"path_mode_3b",
|
|
"path_mode_none",
|
|
"path_mode_unknown",
|
|
] as const;
|
|
|
|
export const COUNT_KEYS = [
|
|
...PACKET_TYPE_KEYS,
|
|
"decode_fail",
|
|
...PATH_MODE_KEYS,
|
|
] as const;
|
|
|
|
const REPORT_COLUMNS = [
|
|
"report_id",
|
|
"device_key6",
|
|
"window_start",
|
|
"window_end",
|
|
"received_at",
|
|
"app_version",
|
|
"cf_country",
|
|
"cf_region",
|
|
"cf_city",
|
|
"cf_latitude",
|
|
"cf_longitude",
|
|
"cf_colo",
|
|
...COUNT_KEYS,
|
|
] as const;
|
|
|
|
const PACKET_TYPE_LABELS: Record<(typeof PACKET_TYPE_KEYS)[number], string> = {
|
|
pt_00: "Request",
|
|
pt_01: "Response",
|
|
pt_02: "Text message",
|
|
pt_03: "Ack",
|
|
pt_04: "Advertisement",
|
|
pt_05: "Group text",
|
|
pt_06: "Group datagram",
|
|
pt_07: "Anonymous request",
|
|
pt_08: "Returned path",
|
|
pt_09: "Trace path",
|
|
pt_0a: "Multipart packet",
|
|
pt_0b: "Control packet",
|
|
pt_0c: "Reserved 0x0C",
|
|
pt_0d: "Reserved 0x0D",
|
|
pt_0e: "Reserved 0x0E",
|
|
pt_0f: "Custom packet",
|
|
};
|
|
|
|
const PATH_MODE_LABELS: Record<(typeof PATH_MODE_KEYS)[number], string> = {
|
|
path_mode_1b: "1-byte path hash",
|
|
path_mode_2b: "2-byte path hash",
|
|
path_mode_3b: "3-byte path hash",
|
|
path_mode_none: "No path bytes",
|
|
path_mode_unknown: "Unknown path mode",
|
|
};
|
|
|
|
const WINDOW_OPTIONS = {
|
|
"24h": {
|
|
label: "Last 24 hours",
|
|
durationMs: 24 * 60 * 60 * 1000,
|
|
bucket: "hour",
|
|
},
|
|
"7d": {
|
|
label: "Last 7 days",
|
|
durationMs: 7 * 24 * 60 * 60 * 1000,
|
|
bucket: "day",
|
|
},
|
|
"30d": {
|
|
label: "Last 30 days",
|
|
durationMs: 30 * 24 * 60 * 60 * 1000,
|
|
bucket: "day",
|
|
},
|
|
} as const;
|
|
|
|
export const jsonHeaders = {
|
|
"content-type": "application/json; charset=utf-8",
|
|
} as const;
|
|
|
|
type CountKey = (typeof COUNT_KEYS)[number];
|
|
type PacketTypeKey = (typeof PACKET_TYPE_KEYS)[number];
|
|
type PathModeKey = (typeof PATH_MODE_KEYS)[number];
|
|
type WindowKey = keyof typeof WINDOW_OPTIONS;
|
|
|
|
export type Counts = Record<CountKey, number>;
|
|
|
|
export interface Env {
|
|
DB: D1Database;
|
|
}
|
|
|
|
export interface IngestPayload {
|
|
reportId: string;
|
|
deviceKey6: string;
|
|
windowStart: string;
|
|
windowEnd: string;
|
|
appVersion: string;
|
|
counts: Counts;
|
|
}
|
|
|
|
export interface CfGeo {
|
|
country: string | null;
|
|
region: string | null;
|
|
city: string | null;
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
colo: string | null;
|
|
}
|
|
|
|
export interface ReportRow extends Counts {
|
|
report_id: string;
|
|
device_key6: string;
|
|
window_start: string;
|
|
window_end: string;
|
|
received_at: string;
|
|
app_version: string | null;
|
|
cf_country: string | null;
|
|
cf_region: string | null;
|
|
cf_city: string | null;
|
|
cf_latitude: number | null;
|
|
cf_longitude: number | null;
|
|
cf_colo: string | null;
|
|
}
|
|
|
|
export interface WindowFilter {
|
|
windowKey: WindowKey;
|
|
label: string;
|
|
sinceIso: string;
|
|
bucket: "hour" | "day";
|
|
}
|
|
|
|
export interface ChartPoint {
|
|
label: string;
|
|
totalPackets: number;
|
|
reports: number;
|
|
}
|
|
|
|
export interface LocationPoint {
|
|
key6: string;
|
|
city: string;
|
|
country: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
}
|
|
|
|
export interface ReporterSummary {
|
|
key6: string;
|
|
lastSeen: string;
|
|
packetTotal: number;
|
|
country: string;
|
|
city: string;
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
}
|
|
|
|
export interface AppVersionEntry {
|
|
version: string;
|
|
reporters: number;
|
|
packets: number;
|
|
}
|
|
|
|
export interface ColoEntry {
|
|
colo: string;
|
|
reporters: number;
|
|
packets: number;
|
|
}
|
|
|
|
export interface TrafficComposition {
|
|
human: number;
|
|
overhead: number;
|
|
acks: number;
|
|
}
|
|
|
|
export interface MultiHopRatio {
|
|
direct: number;
|
|
multiHop: number;
|
|
}
|
|
|
|
export interface CompositionPoint {
|
|
label: string;
|
|
human: number;
|
|
overhead: number;
|
|
}
|
|
|
|
export interface DashboardSummary {
|
|
filter: WindowFilter;
|
|
reportCount: number;
|
|
uniqueDevices: number;
|
|
decodedPackets: number;
|
|
decodeFailures: number;
|
|
packetTypeTotals: Array<{ key: PacketTypeKey; label: string; total: number }>;
|
|
pathModeTotals: Array<{ key: PathModeKey; label: string; total: number }>;
|
|
recentReporters: ReporterSummary[];
|
|
chartPoints: ChartPoint[];
|
|
locationPoints: LocationPoint[];
|
|
appVersions: AppVersionEntry[];
|
|
coloDistribution: ColoEntry[];
|
|
trafficComposition: TrafficComposition;
|
|
multiHopRatio: MultiHopRatio;
|
|
compositionOverTime: CompositionPoint[];
|
|
}
|
|
|
|
export const REPORT_INSERT_SQL = `
|
|
INSERT OR IGNORE INTO reports (
|
|
${REPORT_COLUMNS.join(", ")}
|
|
) VALUES (
|
|
${REPORT_COLUMNS.map(() => "?").join(", ")}
|
|
)`.trim();
|
|
|
|
export function createEmptyCounts(): Counts {
|
|
return Object.fromEntries(
|
|
COUNT_KEYS.map((key) => [key, 0]),
|
|
) as Counts;
|
|
}
|
|
|
|
export function validateIngestPayload(payload: unknown): IngestPayload {
|
|
if (typeof payload !== "object" || payload === null) {
|
|
throw new Error("Body must be a JSON object.");
|
|
}
|
|
const record = payload as Record<string, unknown>;
|
|
const reportId = asTrimmedString(record.reportId, "reportId");
|
|
const deviceKey6 = asTrimmedString(record.deviceKey6, "deviceKey6");
|
|
if (!/^[0-9a-f]{12}$/.test(deviceKey6)) {
|
|
throw new Error("deviceKey6 must be 12 lowercase hex characters.");
|
|
}
|
|
const windowStart = asIsoString(record.windowStart, "windowStart");
|
|
const windowEnd = asIsoString(record.windowEnd, "windowEnd");
|
|
if (Date.parse(windowEnd) < Date.parse(windowStart)) {
|
|
throw new Error("windowEnd must not be earlier than windowStart.");
|
|
}
|
|
const appVersion = asTrimmedString(record.appVersion, "appVersion");
|
|
const counts = validateCounts(record.counts);
|
|
return {
|
|
reportId,
|
|
deviceKey6,
|
|
windowStart,
|
|
windowEnd,
|
|
appVersion,
|
|
counts,
|
|
};
|
|
}
|
|
|
|
export function extractCfGeo(request: Request): CfGeo {
|
|
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
|
|
return {
|
|
country: asNullableString(cf?.country),
|
|
region: asNullableString(cf?.region),
|
|
city: asNullableString(cf?.city),
|
|
latitude: asNullableNumber(cf?.latitude),
|
|
longitude: asNullableNumber(cf?.longitude),
|
|
colo: asNullableString(cf?.colo),
|
|
};
|
|
}
|
|
|
|
export function buildWindowFilter(
|
|
requestedWindow: string | null,
|
|
now: Date = new Date(),
|
|
): WindowFilter {
|
|
const windowKey =
|
|
requestedWindow === "24h" ||
|
|
requestedWindow === "7d" ||
|
|
requestedWindow === "30d"
|
|
? requestedWindow
|
|
: "24h";
|
|
const option = WINDOW_OPTIONS[windowKey];
|
|
return {
|
|
windowKey,
|
|
label: option.label,
|
|
sinceIso: new Date(now.getTime() - option.durationMs).toISOString(),
|
|
bucket: option.bucket,
|
|
};
|
|
}
|
|
|
|
export async function loadDashboardSummary(
|
|
env: Env,
|
|
requestedWindow: string | null,
|
|
now: Date = new Date(),
|
|
): Promise<DashboardSummary> {
|
|
const filter = buildWindowFilter(requestedWindow, now);
|
|
const query = await env.DB.prepare(
|
|
"SELECT * FROM reports WHERE window_end >= ? ORDER BY window_end DESC",
|
|
)
|
|
.bind(filter.sinceIso)
|
|
.all<ReportRow>();
|
|
const rows = (query.results ?? []) as ReportRow[];
|
|
return summarizeRows(rows, filter);
|
|
}
|
|
|
|
export function summarizeRows(
|
|
rows: ReportRow[],
|
|
filter: WindowFilter,
|
|
): DashboardSummary {
|
|
const packetTypeTotals = PACKET_TYPE_KEYS.map((key) => ({
|
|
key,
|
|
label: PACKET_TYPE_LABELS[key],
|
|
total: sumRows(rows, key),
|
|
})).sort((left, right) => right.total - left.total);
|
|
const pathModeTotals = PATH_MODE_KEYS.map((key) => ({
|
|
key,
|
|
label: PATH_MODE_LABELS[key],
|
|
total: sumRows(rows, key),
|
|
})).sort((left, right) => right.total - left.total);
|
|
const decodedPackets = PACKET_TYPE_KEYS.reduce(
|
|
(total, key) => total + sumRows(rows, key),
|
|
0,
|
|
);
|
|
const decodeFailures = sumRows(rows, "decode_fail");
|
|
const reporterMap = new Map<string, ReporterSummary>();
|
|
const chartBuckets = new Map<string, ChartPoint>();
|
|
|
|
// per-version and per-colo accumulators
|
|
const versionMap = new Map<string, { reporters: Set<string>; packets: number }>();
|
|
const coloMap = new Map<string, { reporters: Set<string>; packets: number }>();
|
|
const compositionBuckets = new Map<string, { human: number; overhead: number }>();
|
|
|
|
for (const row of rows) {
|
|
const packetTotal =
|
|
decodeFailuresForRow(row) +
|
|
PACKET_TYPE_KEYS.reduce((total, key) => total + row[key], 0);
|
|
const existingReporter = reporterMap.get(row.device_key6);
|
|
if (!existingReporter) {
|
|
reporterMap.set(row.device_key6, {
|
|
key6: row.device_key6,
|
|
lastSeen: row.window_end,
|
|
packetTotal,
|
|
country: row.cf_country ?? "Unknown",
|
|
city: row.cf_city ?? row.cf_region ?? "Unknown",
|
|
latitude: row.cf_latitude,
|
|
longitude: row.cf_longitude,
|
|
});
|
|
} else {
|
|
existingReporter.packetTotal += packetTotal;
|
|
if (row.window_end > existingReporter.lastSeen) {
|
|
existingReporter.lastSeen = row.window_end;
|
|
existingReporter.country = row.cf_country ?? existingReporter.country;
|
|
existingReporter.city =
|
|
row.cf_city ?? row.cf_region ?? existingReporter.city;
|
|
existingReporter.latitude = row.cf_latitude;
|
|
existingReporter.longitude = row.cf_longitude;
|
|
}
|
|
}
|
|
|
|
const bucketKey = formatBucket(row.window_end, filter.bucket);
|
|
const existingBucket = chartBuckets.get(bucketKey);
|
|
if (existingBucket) {
|
|
existingBucket.totalPackets += packetTotal;
|
|
existingBucket.reports += 1;
|
|
} else {
|
|
chartBuckets.set(bucketKey, {
|
|
label: bucketKey,
|
|
totalPackets: packetTotal,
|
|
reports: 1,
|
|
});
|
|
}
|
|
|
|
// app version
|
|
const ver = row.app_version ?? "unknown";
|
|
const verEntry = versionMap.get(ver);
|
|
if (verEntry) {
|
|
verEntry.reporters.add(row.device_key6);
|
|
verEntry.packets += packetTotal;
|
|
} else {
|
|
versionMap.set(ver, { reporters: new Set([row.device_key6]), packets: packetTotal });
|
|
}
|
|
|
|
// CF colo
|
|
const colo = row.cf_colo ?? "unknown";
|
|
const coloEntry = coloMap.get(colo);
|
|
if (coloEntry) {
|
|
coloEntry.reporters.add(row.device_key6);
|
|
coloEntry.packets += packetTotal;
|
|
} else {
|
|
coloMap.set(colo, { reporters: new Set([row.device_key6]), packets: packetTotal });
|
|
}
|
|
|
|
// composition over time (human = text + group_text, overhead = rest)
|
|
const humanPackets = row.pt_02 + row.pt_05;
|
|
const overheadPackets = packetTotal - humanPackets;
|
|
const compBucket = compositionBuckets.get(bucketKey);
|
|
if (compBucket) {
|
|
compBucket.human += humanPackets;
|
|
compBucket.overhead += overheadPackets;
|
|
} else {
|
|
compositionBuckets.set(bucketKey, { human: humanPackets, overhead: overheadPackets });
|
|
}
|
|
}
|
|
|
|
const recentReporters = [...reporterMap.values()]
|
|
.sort((left, right) => right.lastSeen.localeCompare(left.lastSeen))
|
|
.slice(0, 12);
|
|
const locationPoints = recentReporters
|
|
.filter(
|
|
(
|
|
reporter,
|
|
): reporter is ReporterSummary & { latitude: number; longitude: number } =>
|
|
reporter.latitude !== null && reporter.longitude !== null,
|
|
)
|
|
.map((reporter) => ({
|
|
key6: reporter.key6,
|
|
city: reporter.city,
|
|
country: reporter.country,
|
|
latitude: reporter.latitude,
|
|
longitude: reporter.longitude,
|
|
}));
|
|
|
|
// human = text messages (pt_02 + pt_05), acks = pt_03, overhead = everything else
|
|
const humanTotal = sumRows(rows, "pt_02") + sumRows(rows, "pt_05");
|
|
const acksTotal = sumRows(rows, "pt_03");
|
|
const overheadTotal = decodedPackets - humanTotal - acksTotal;
|
|
|
|
// multi-hop: direct = path_mode_none, multiHop = 1b+2b+3b
|
|
const directTotal = sumRows(rows, "path_mode_none");
|
|
const multiHopTotal = sumRows(rows, "path_mode_1b") + sumRows(rows, "path_mode_2b") + sumRows(rows, "path_mode_3b");
|
|
|
|
return {
|
|
filter,
|
|
reportCount: rows.length,
|
|
uniqueDevices: reporterMap.size,
|
|
decodedPackets,
|
|
decodeFailures,
|
|
packetTypeTotals,
|
|
pathModeTotals,
|
|
recentReporters,
|
|
chartPoints: [...chartBuckets.values()].sort((left, right) =>
|
|
left.label.localeCompare(right.label),
|
|
),
|
|
locationPoints,
|
|
appVersions: [...versionMap.entries()]
|
|
.map(([version, entry]) => ({ version, reporters: entry.reporters.size, packets: entry.packets }))
|
|
.sort((left, right) => right.packets - left.packets),
|
|
coloDistribution: [...coloMap.entries()]
|
|
.map(([colo, entry]) => ({ colo, reporters: entry.reporters.size, packets: entry.packets }))
|
|
.sort((left, right) => right.packets - left.packets),
|
|
trafficComposition: { human: humanTotal, overhead: overheadTotal, acks: acksTotal },
|
|
multiHopRatio: { direct: directTotal, multiHop: multiHopTotal },
|
|
compositionOverTime: [...compositionBuckets.entries()]
|
|
.map(([label, entry]) => ({ label, ...entry }))
|
|
.sort((left, right) => left.label.localeCompare(right.label)),
|
|
};
|
|
}
|
|
|
|
function validateCounts(value: unknown): Counts {
|
|
if (typeof value !== "object" || value === null) {
|
|
throw new Error("counts must be an object.");
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const counts = createEmptyCounts();
|
|
for (const key of COUNT_KEYS) {
|
|
const rawValue = record[key];
|
|
if (typeof rawValue !== "number" || !Number.isFinite(rawValue)) {
|
|
throw new Error(`counts.${key} must be a number.`);
|
|
}
|
|
if (rawValue < 0) {
|
|
throw new Error(`counts.${key} must be zero or greater.`);
|
|
}
|
|
counts[key] = Math.trunc(rawValue);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function sumRows(rows: ReportRow[], key: CountKey): number {
|
|
return rows.reduce((total, row) => total + (row[key] ?? 0), 0);
|
|
}
|
|
|
|
function decodeFailuresForRow(row: ReportRow): number {
|
|
return row.decode_fail ?? 0;
|
|
}
|
|
|
|
function formatBucket(value: string, bucket: "hour" | "day"): string {
|
|
const date = new Date(value);
|
|
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
|
|
const day = `${date.getUTCDate()}`.padStart(2, "0");
|
|
if (bucket === "day") {
|
|
return `${date.getUTCFullYear()}-${month}-${day}`;
|
|
}
|
|
const hour = `${date.getUTCHours()}`.padStart(2, "0");
|
|
return `${date.getUTCFullYear()}-${month}-${day} ${hour}:00`;
|
|
}
|
|
|
|
function asTrimmedString(value: unknown, fieldName: string): string {
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
throw new Error(`${fieldName} must be a non-empty string.`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function asIsoString(value: unknown, fieldName: string): string {
|
|
const stringValue = asTrimmedString(value, fieldName);
|
|
if (Number.isNaN(Date.parse(stringValue))) {
|
|
throw new Error(`${fieldName} must be a valid ISO-8601 timestamp.`);
|
|
}
|
|
return new Date(stringValue).toISOString();
|
|
}
|
|
|
|
function asNullableString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0
|
|
? value.trim()
|
|
: null;
|
|
}
|
|
|
|
function asNullableNumber(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|