Create resilient, data-driven dashboard panels with integrated API handling and UI components, combining circuit breaker patterns with vanilla TypeScript panel architecture.
This skill merges the best of data-service and panel-component to create panels that are both resilient to API failures and easy to implement. Each panel manages its own data fetching with built-in circuit breaker patterns and renders content using vanilla TypeScript.
Panel class with built-in loading/error states and content rendering.Create src/utils/circuit-breaker.ts (same as in data-service but with enhanced logging):
interface CircuitState {
failures: number;
cooldownUntil: number;
}
interface CacheEntry<T> {
data: T;
timestamp: number;
}
export interface CircuitBreakerOptions {
name: string;
maxFailures?: number;
cooldownMs?: number;
cacheTtlMs?: number;
}
export class CircuitBreaker<T> {
private state: CircuitState = { failures: 0, cooldownUntil: 0 };
private cache: CacheEntry<T> | null = null;
private name: string;
private maxFailures: number;
private cooldownMs: number;
private cacheTtlMs: number;
constructor(options: CircuitBreakerOptions) {
this.name = options.name;
this.maxFailures = options.maxFailures ?? 2;
this.cooldownMs = options.cooldownMs ?? 5 * 60 * 1000; // 5 minutes
this.cacheTtlMs = options.cacheTtlMs ?? 10 * 60 * 1000; // 10 minutes
}
isOnCooldown(): boolean {
if (Date.now() < this.state.cooldownUntil) return true;
if (this.state.cooldownUntil > 0) {
this.state = { failures: 0, cooldownUntil: 0 };
}
return false;
}
getCached(): T | null {
if (this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) {
return this.cache.data;
}
return null;
}
recordSuccess(data: T): void {
this.state = { failures: 0, cooldownUntil: 0 };
this.cache = { data, timestamp: Date.now() };
}
recordFailure(error?: string): void {
this.state.failures++;
if (this.state.failures >= this.maxFailures) {
this.state.cooldownUntil = Date.now() + this.cooldownMs;
console.warn(`[${this.name}] Cooldown for ${this.cooldownMs / 1000}s`);
}
}
async execute<R extends T>(fn: () => Promise<R>, defaultValue: R): Promise<R> {
if (this.isOnCooldown()) {
const cached = this.getCached();
return (cached as R) ?? defaultValue;
}
const cached = this.getCached();
if (cached !== null) return cached as R;
try {
const result = await fn();
this.recordSuccess(result);
return result;
} catch (e) {
console.error(`[${this.name}] Failed:`, e);
this.recordFailure(String(e));
return defaultValue;
}
}
}
export function createCircuitBreaker<T>(options: CircuitBreakerOptions): CircuitBreaker<T> {
return new CircuitBreaker<T>(options);
}
Create src/components/Panel.ts with enhanced error handling and retry logic:
export interface PanelOptions {
id: string;
title: string;
showCount?: boolean;
className?: string;
}
export class Panel {
protected element: HTMLElement;
protected content: HTMLElement;
protected header: HTMLElement;
protected countEl: HTMLElement | null = null;
protected panelId: string;
private _fetching = false;
constructor(options: PanelOptions) {
this.panelId = options.id;
this.element = document.createElement('div');
this.element.className = `panel ${options.className || ''}`;
this.element.dataset.panel = options.id;
// Header
this.header = document.createElement('div');
this.header.className = 'panel-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'panel-header-left';
const title = document.createElement('span');
title.className = 'panel-title';
title.textContent = options.title;
headerLeft.appendChild(title);
this.header.appendChild(headerLeft);
// Count badge (optional)
if (options.showCount) {
this.countEl = document.createElement('span');
this.countEl.className = 'panel-count';
this.countEl.textContent = '0';
this.header.appendChild(this.countEl);
}
// Content area
this.content = document.createElement('div');
this.content.className = 'panel-content';
this.content.id = `${options.id}Content`;
this.element.appendChild(this.header);
this.element.appendChild(this.content);
this.showLoading();
}
public getElement(): HTMLElement { return this.element; }
public showLoading(message = 'Loading...'): void {
this.content.innerHTML = `
<div class="panel-loading">
<div class="panel-loading-spinner"></div>
<div class="panel-loading-text">${message}</div>
</div>`;
}
public showError(message = 'Failed to load', onRetry?: () => void): void {
this.content.innerHTML = `
<div class="panel-error-state">
<div class="panel-error-msg">${message}</div>
${onRetry ? '<button class="panel-retry-btn" data-panel-retry>Retry</button>' : ''}
</div>`;
if (onRetry) {
this.content.querySelector('[data-panel-retry]')?.addEventListener('click', onRetry);
}
}
public setContent(html: string): void {
this.content.innerHTML = html;
}
public setCount(count: number): void {
if (this.countEl) this.countEl.textContent = count.toString();
}
public show(): void { this.element.classList.remove('hidden'); }
public hide(): void { this.element.classList.add('hidden'); }
protected setFetching(v: boolean): void { this._fetching = v; }
protected get isFetching(): boolean { return this._fetching; }
public destroy(): void {
this.element.remove();
}
}
import { Panel } from './Panel';
import { createCircuitBreaker } from '../utils/circuit-breaker';
interface StockQuote {
symbol: string;
name: string;
price: number | null;
change: number | null;
sparkline?: number[];
}
const breaker = createCircuitBreaker<StockQuote[]>({
name: 'StockMarket',
cacheTtlMs: 60_000,
});
export class StockPanel extends Panel {
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({ id: 'stocks', title: 'Stock Market', showCount: true });
this.fetchData();
this.refreshTimer = setInterval(() => this.fetchData(), 60_000);
}
private async fetchData(): Promise<void> {
if (this.isFetching) return;
this.setFetching(true);
try {
const quotes = await breaker.execute(async () => {
const resp = await fetch('/api/stocks');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}, []);
this.render(quotes);
this.setCount(quotes.length);
} catch (err) {
this.showError('Failed to load stock data', () => this.fetchData());
} finally {
this.setFetching(false);
}
}
private render(quotes: StockQuote[]): void {
const rows = quotes.map(q => `
<div class="stock-row">
<span class="stock-symbol">${q.symbol}</span>
<span class="stock-name">${q.name}</span>
<span class="stock-price">${q.price != null ? '$' + q.price.toFixed(2) : '—'}</span>
<span class="stock-change ${(q.change ?? 0) >= 0 ? 'positive' : 'negative'}">
${q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) + '%' : '—'}
</span>
</div>
`).join('');
this.setContent(`<div class="stock-list">${rows}</div>`);
}
public override destroy(): void {
if (this.refreshTimer) clearInterval(this.refreshTimer);
super.destroy();
}
}
Panel and implement fetchData() and render().