diff --git a/dashboard/src/pages/stats.astro b/dashboard/src/pages/stats.astro index 76a56a7..1419602 100644 --- a/dashboard/src/pages/stats.astro +++ b/dashboard/src/pages/stats.astro @@ -157,6 +157,7 @@ const tabs = [ status: "healthy" | "degraded" | "error"; error: string | null; consecutive_failures: number; + last_extended_sync_date?: string | null; } interface ServiceStats { @@ -566,13 +567,6 @@ const tabs = [ ? "bg-amber-nerv" : "bg-red-nerv"; - const consecutiveFailuresHtml = ch.consecutive_failures > 0 - ? `
-
${ch.consecutive_failures}
-
Consecutive Failures
-
` - : ""; - return `
@@ -582,21 +576,25 @@ const tabs = [
${ch.status}
-
-
-
${formatTimeAgo(ch.last_sync_at)}
-
Last Sync
+
+
+ Last Sync + ${formatTimeAgo(ch.last_sync_at)}
-
-
${formatTimeAgo(ch.last_post_at)}
-
Last Post
+
+ Last Post + ${formatTimeAgo(ch.last_post_at)}
-
-
${ch.events_posted}
-
Events Posted
+
+ Events Posted + ${ch.events_posted} +
+
+ Failures + ${ch.consecutive_failures}
- ${consecutiveFailuresHtml} - ${ch.error ? `
${ch.error}
` : ""} + ${ch.last_extended_sync_date ? `
Extended Sync${formatTimeAgo(ch.last_extended_sync_date)}
` : ""} + ${ch.error ? `
${ch.error}
` : ""}
`; diff --git a/src/api/index.ts b/src/api/index.ts index 5bea6e9..4481120 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -78,6 +78,9 @@ function formatChannelStats( status: s.status, error: s.error, consecutive_failures: s.consecutiveFailures, + last_extended_sync_date: s.lastExtendedSyncDate + ? new Date(s.lastExtendedSyncDate).toISOString() + : null, }; } return result; @@ -90,6 +93,7 @@ interface ChannelStatsResponse { status: "healthy" | "degraded" | "error"; error: string | null; consecutive_failures: number; + last_extended_sync_date?: string | null; } /** diff --git a/src/channels/calendar/index.ts b/src/channels/calendar/index.ts index 847742e..5a2717a 100644 --- a/src/channels/calendar/index.ts +++ b/src/channels/calendar/index.ts @@ -182,6 +182,9 @@ export class CalendarChannel implements Channel { status: this.state.status as ChannelStats["status"], error: this.state.error, consecutiveFailures: this.state.consecutiveFailures ?? 0, + lastExtendedSyncDate: this.state.lastExtendedSyncDate + ? new Date(this.state.lastExtendedSyncDate).getTime() + : null, }; } // Fallback to in-memory state for tests @@ -192,6 +195,9 @@ export class CalendarChannel implements Channel { status: this.memoryState.status as ChannelStats["status"], error: this.memoryState.error, consecutiveFailures: this.memoryState.consecutiveFailures, + lastExtendedSyncDate: this.memoryState.lastExtendedSyncDate + ? new Date(this.memoryState.lastExtendedSyncDate).getTime() + : null, }; } diff --git a/src/channels/index.ts b/src/channels/index.ts index c2c70f7..5315162 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -25,6 +25,8 @@ export interface ChannelStats { error: string | null; /** Number of consecutive failures (timeouts or errors). Resets on success. */ consecutiveFailures: number; + /** When the last extended sync (30-day window) occurred (calendar channel only). */ + lastExtendedSyncDate?: number | null; } // --- Channel interface --- diff --git a/src/channels/telegram/index.ts b/src/channels/telegram/index.ts index eaffaf4..56aee51 100644 --- a/src/channels/telegram/index.ts +++ b/src/channels/telegram/index.ts @@ -114,8 +114,12 @@ export class TelegramChannel implements Channel { while (this.running) { try { await this.pollTelegramUpdates(); - // Reset backoff on success + // Reset backoff and error state on success this.ingestionBackoffMs = 0; + const s = this.state ?? this.memoryState; + s.status = "healthy"; + s.error = null; + s.consecutiveFailures = 0; } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); log(`ingestion error: ${errorMsg}`); @@ -222,10 +226,12 @@ export class TelegramChannel implements Channel { while (this.running) { try { const delivered = await this.deliverOutboxMessages(); - if (delivered > 0) { - // Reset backoff on successful delivery - this.deliveryBackoffMs = 0; - } else { + // Reset backoff and error state on success (even if no messages) + this.deliveryBackoffMs = 0; + const s = this.state ?? this.memoryState; + s.status = "healthy"; + s.error = null; + if (delivered === 0) { // No messages, wait before next poll await this.sleep(pollInterval); } diff --git a/test/telegram-channel.test.ts b/test/telegram-channel.test.ts index a3f2fbf..ae95b0a 100644 --- a/test/telegram-channel.test.ts +++ b/test/telegram-channel.test.ts @@ -441,10 +441,10 @@ describe("TelegramChannel error handling", () => { async () => { errorCount++; errorTimes.push(Date.now()); - if (errorCount <= 3) { + if (errorCount <= 2) { throw new Error("Simulated API failure"); } - // After 3 errors, succeed and stop + // After 2 errors, succeed and stop await new Promise((r) => setTimeout(r, 100)); return []; }, @@ -453,17 +453,17 @@ describe("TelegramChannel error handling", () => { const channel = new TelegramChannel(cortex, DEFAULT_CONFIG); await channel.start(); - // Wait for at least one backoff cycle (1s min) plus buffer - await new Promise((r) => setTimeout(r, 1200)); + // Wait for: error 1 (immediate) + 1s backoff + error 2 + 2s backoff + success + buffer + await new Promise((r) => setTimeout(r, 3500)); await channel.stop(); // Verify at least 2 errors occurred (first immediate, second after 1s backoff) expect(errorCount).toBeGreaterThanOrEqual(2); - // Verify stats show degraded status + // After recovery (successful poll returns []), status should be healthy const stats = channel.getStats(); - expect(stats.consecutiveFailures).toBeGreaterThanOrEqual(1); - expect(stats.status).toBe("degraded"); + expect(stats.status).toBe("healthy"); + expect(stats.consecutiveFailures).toBe(0); // Verify backoff is applied (gap between errors should be ~1000ms) if (errorTimes.length >= 2) {