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
19 changes: 19 additions & 0 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import './styles/animations.css';
import './styles/views.css';

export class App {
private static readonly INITIAL_LOAD_TIMEOUT_MS = 15_000;

private root: HTMLElement;
private mapEngine: MapEngine | null = null;
private panelManager: PanelManager | null = null;
private sourceManager: SourceManager | null = null;
private aiManager: AIManager | null = null;
private idleDetector: IdleDetector | null = null;
private mediaQueryCleanup: (() => void) | null = null;
private initialLoadTimer: ReturnType<typeof setTimeout> | null = null;

constructor(root: HTMLElement) {
this.root = root;
Expand Down Expand Up @@ -115,6 +118,14 @@ export class App {
// Start fetching data
this.sourceManager.startAll();

// Initial-load timeout: degrade panels that haven't received data
this.initialLoadTimer = setTimeout(() => {
this.panelManager?.degradeUnreceivedPanels(
'No data sources responding. Check your source configuration.',
);
this.initialLoadTimer = null;
}, App.INITIAL_LOAD_TIMEOUT_MS);

// Initial AI brief
if (this.aiManager.isEnabled()) {
this.generateAIBrief();
Expand Down Expand Up @@ -169,6 +180,10 @@ export class App {
});
} catch (err) {
console.warn('AI brief generation failed:', err);
this.panelManager?.updatePanel('ai-insights', {
type: 'degraded',
message: 'AI analysis requires API keys. Run `forge ai configure` to set up a provider.',
});
}
}

Expand Down Expand Up @@ -275,6 +290,10 @@ export class App {
}

destroy(): void {
if (this.initialLoadTimer !== null) {
clearTimeout(this.initialLoadTimer);
this.initialLoadTimer = null;
}
this.mediaQueryCleanup?.();
this.mediaQueryCleanup = null;
this.sourceManager?.stopAll();
Expand Down
111 changes: 111 additions & 0 deletions src/core/panels/PanelBase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from 'vitest';
import { PanelBase, type PanelConfig } from './PanelBase.js';

class TestPanel extends PanelBase {
rendered = false;
lastData: unknown = undefined;

constructor(container: HTMLElement, config: PanelConfig) {
super(container, config);
}

render(): void {
this.rendered = true;
this.showSkeleton(2);
}

update(data: unknown): void {
this.lastData = data;
this.markDataReceived();
}

destroy(): void {
this.cleanupTimers();
this.container.innerHTML = '';
}
}

const makePanel = () => {
const container = document.createElement('div');
const panel = new TestPanel(container, {
name: 'test-panel',
type: 'test',
displayName: 'Test Panel',
position: 0,
config: {},
});
return { panel, container };
};

describe('PanelBase degradation', () => {
beforeEach(() => {
document.body.innerHTML = '';
});

it('renderDegraded() creates the degraded element', () => {
const { panel, container } = makePanel();
panel.renderDegraded('Something went wrong');
const el = container.querySelector('.panel-degraded');
expect(el).not.toBeNull();
expect(el?.textContent).toContain('Something went wrong');
});

it('clearDegraded() removes the degraded element', () => {
const { panel, container } = makePanel();
panel.renderDegraded('Error message');
expect(container.querySelector('.panel-degraded')).not.toBeNull();

panel.clearDegraded();
expect(container.querySelector('.panel-degraded')).toBeNull();
});

it('markDataReceived() calls clearDegraded()', () => {
const { panel, container } = makePanel();
panel.render();
panel.renderDegraded('Waiting for data...');
expect(container.querySelector('.panel-degraded')).not.toBeNull();

// update() calls markDataReceived() which should clear degraded state
panel.update({ some: 'data' });
expect(container.querySelector('.panel-degraded')).toBeNull();
});

it('data arriving after degradation clears degraded state', () => {
const { panel, container } = makePanel();
panel.render();

// First: panel is degraded
panel.renderDegraded('No data sources responding.');
expect(container.querySelector('.panel-degraded')).not.toBeNull();

// Then: real data arrives
panel.update({ items: [1, 2, 3] });
expect(container.querySelector('.panel-degraded')).toBeNull();
expect(panel.getHasReceivedData()).toBe(true);
});

it('renderDegraded() replaces previous degraded element', () => {
const { panel, container } = makePanel();
panel.renderDegraded('First message');
panel.renderDegraded('Second message');
const els = container.querySelectorAll('.panel-degraded');
expect(els).toHaveLength(1);
expect(els[0].textContent).toContain('Second message');
});

it('renderDegraded() wraps backtick commands with configure-hint class', () => {
const { panel, container } = makePanel();
panel.renderDegraded('Run `forge ai configure` to set up.');
const hint = container.querySelector('.panel-configure-hint');
expect(hint).not.toBeNull();
expect(hint?.textContent).toContain('`forge ai configure`');
});

it('getHasReceivedData() returns false before data and true after', () => {
const { panel } = makePanel();
expect(panel.getHasReceivedData()).toBe(false);
panel.update({ test: true });
expect(panel.getHasReceivedData()).toBe(true);
});
});
25 changes: 25 additions & 0 deletions src/core/panels/PanelBase.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify';

export interface PanelConfig {
name: string;
type: string;
Expand All @@ -13,6 +15,7 @@ export abstract class PanelBase {
private panelElement: HTMLElement | null = null;
private pulseTimer: ReturnType<typeof setTimeout> | null = null;
private skeletonTimer: ReturnType<typeof setTimeout> | null = null;
private degradedElement: HTMLElement | null = null;

constructor(container: HTMLElement, config: PanelConfig) {
this.container = container;
Expand All @@ -26,6 +29,7 @@ export abstract class PanelBase {
getName(): string { return this.config.name; }
getDisplayName(): string { return this.config.displayName; }
getPosition(): number { return this.config.position; }
getHasReceivedData(): boolean { return this.hasReceivedData; }

setPanelElement(el: HTMLElement): void {
this.panelElement = el;
Expand Down Expand Up @@ -77,11 +81,32 @@ export abstract class PanelBase {
if (this.skeletonTimer !== null) { clearTimeout(this.skeletonTimer); this.skeletonTimer = null; }
}

renderDegraded(message: string): void {
this.clearDegraded();
const el = document.createElement('div');
el.className = 'panel-degraded';
const sanitized = DOMPurify.sanitize(message, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
el.innerHTML = sanitized.replace(
/(`[^`]+`)/g,
'<span class="panel-configure-hint">$1</span>',
);
this.container.appendChild(el);
this.degradedElement = el;
}

clearDegraded(): void {
if (this.degradedElement) {
this.degradedElement.remove();
this.degradedElement = null;
}
}

protected markDataReceived(): void {
if (!this.hasReceivedData) {
this.hasReceivedData = true;
this.hideSkeleton();
}
this.clearDegraded();
this.triggerPulse();
}

Expand Down
19 changes: 19 additions & 0 deletions src/core/panels/PanelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,25 @@ export class PanelManager {
return match ? match[1] : null;
}

degradeUnreceivedPanels(message: string): void {
for (const instances of this.panels.values()) {
for (const panel of instances) {
if (!panel.getHasReceivedData()) {
panel.renderDegraded(message);
}
}
}
}

hasAnyDataReceived(): boolean {
for (const instances of this.panels.values()) {
for (const panel of instances) {
if (panel.getHasReceivedData()) return true;
}
}
return false;
}

updatePanel(name: string, data: unknown): void {
this.lastPanelData.set(name, data);
const instances = this.panels.get(name);
Expand Down
10 changes: 10 additions & 0 deletions src/core/panels/types/AIBriefPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,17 @@ export class AIBriefPanel extends PanelBase {

update(data: unknown): void {
if (!data || typeof data !== 'object') return;

const maybeDegraded = data as { type?: string; message?: string };
if (maybeDegraded.type === 'degraded' && maybeDegraded.message) {
this.hideSkeleton();
this.renderDegraded(maybeDegraded.message);
this.hasReceivedData = true;
return;
}

const briefData = data as { summary?: string; timestamp?: string };
if (!briefData.summary) return; // Not AI brief data (e.g., source items from updateAll)

this.markDataReceived();

Expand Down
4 changes: 4 additions & 0 deletions src/core/panels/types/EntityTrackerPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ export class EntityTrackerPanel extends PanelBase {
<div class="entity-tracker-items"></div>
</div>
`;
this.showSkeleton(3);
}

update(data: unknown): void {
if (!Array.isArray(data)) return;
this.entities = data as TrackedEntity[];

this.markDataReceived();
this.renderEntities();
}

Expand All @@ -43,6 +46,7 @@ export class EntityTrackerPanel extends PanelBase {
}

destroy(): void {
this.cleanupTimers();
this.container.innerHTML = '';
}
}
6 changes: 5 additions & 1 deletion src/core/panels/types/ServiceStatusPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class ServiceStatusPanel extends PanelBase {
}

private renderStatus(): void {
this.markDataReceived();
const itemsEl = this.container.querySelector('.service-status-items');
if (!itemsEl) return;

Expand All @@ -60,5 +61,8 @@ export class ServiceStatusPanel extends PanelBase {
}).join('');
}

destroy(): void { this.container.innerHTML = ''; }
destroy(): void {
this.cleanupTimers();
this.container.innerHTML = '';
}
}
33 changes: 33 additions & 0 deletions src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,39 @@ html, body {
background: var(--bg-secondary);
}

/* Panel Degraded State */
.panel-degraded {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 1.5rem 1rem;
background: rgba(255, 255, 255, 0.02);
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.5;
margin: 0.25rem 0;
}

.panel-configure-hint {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
background: rgba(255, 255, 255, 0.05);
padding: 0.1rem 0.35rem;
border-radius: 3px;
color: var(--accent);
font-size: 0.75rem;
}

[data-theme="light"] .panel-degraded {
background: rgba(0, 0, 0, 0.02);
}

[data-theme="light"] .panel-configure-hint {
background: rgba(0, 0, 0, 0.05);
}

/* Scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
Expand Down