Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 17 additions & 19 deletions dashboard/src/pages/stats.astro
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ const tabs = [
status: "healthy" | "degraded" | "error";
error: string | null;
consecutive_failures: number;
last_extended_sync_date?: string | null;
}

interface ServiceStats {
Expand Down Expand Up @@ -566,13 +567,6 @@ const tabs = [
? "bg-amber-nerv"
: "bg-red-nerv";

const consecutiveFailuresHtml = ch.consecutive_failures > 0
? `<div>
<div class="font-mono text-lg text-red-nerv">${ch.consecutive_failures}</div>
<div class="font-mono text-xs text-red-nerv">Consecutive Failures</div>
</div>`
: "";

return `
<div class="p-3 bg-nerv-bg/50 rounded border border-nerv-border">
<div class="flex items-center justify-between mb-3">
Expand All @@ -582,21 +576,25 @@ const tabs = [
</div>
<span class="font-mono text-xs text-text-muted">${ch.status}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<div class="font-mono text-sm text-text">${formatTimeAgo(ch.last_sync_at)}</div>
<div class="font-mono text-xs text-text-muted">Last Sync</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="font-mono text-xs text-text-muted">Last Sync</span>
<span class="font-mono text-sm text-text">${formatTimeAgo(ch.last_sync_at)}</span>
</div>
<div>
<div class="font-mono text-sm text-text">${formatTimeAgo(ch.last_post_at)}</div>
<div class="font-mono text-xs text-text-muted">Last Post</div>
<div class="flex justify-between">
<span class="font-mono text-xs text-text-muted">Last Post</span>
<span class="font-mono text-sm text-text">${formatTimeAgo(ch.last_post_at)}</span>
</div>
<div>
<div class="font-mono text-lg text-amber-nerv">${ch.events_posted}</div>
<div class="font-mono text-xs text-text-muted">Events Posted</div>
<div class="flex justify-between">
<span class="font-mono text-xs text-text-muted">Events Posted</span>
<span class="font-mono text-sm text-amber-nerv">${ch.events_posted}</span>
</div>
<div class="flex justify-between">
<span class="font-mono text-xs text-text-muted">Failures</span>
<span class="font-mono text-sm ${ch.consecutive_failures > 0 ? 'text-red-nerv' : 'text-green-nerv'}">${ch.consecutive_failures}</span>
</div>
${consecutiveFailuresHtml}
${ch.error ? `<div class="col-span-2"><div class="font-mono text-xs text-red-nerv truncate" title="${ch.error}">${ch.error}</div></div>` : ""}
${ch.last_extended_sync_date ? `<div class="flex justify-between"><span class="font-mono text-xs text-text-muted">Extended Sync</span><span class="font-mono text-sm text-text">${formatTimeAgo(ch.last_extended_sync_date)}</span></div>` : ""}
${ch.error ? `<div class="mt-2 pt-2 border-t border-nerv-border"><div class="font-mono text-xs text-red-nerv">${ch.error}</div></div>` : ""}
</div>
</div>
`;
Expand Down
4 changes: 4 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -90,6 +93,7 @@ interface ChannelStatsResponse {
status: "healthy" | "degraded" | "error";
error: string | null;
consecutive_failures: number;
last_extended_sync_date?: string | null;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/channels/calendar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/channels/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
16 changes: 11 additions & 5 deletions src/channels/telegram/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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);
}
Expand Down
14 changes: 7 additions & 7 deletions test/telegram-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
},
Expand All @@ -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) {
Expand Down