OpenSpace panel-breaker-stateful
Unified dashboard panel pattern combining circuit breaker resilience with enhanced UI features like retry logic, state persistence, and detailed error handling.
install
source · Clone the upstream repo
git clone https://github.com/HKUDS/OpenSpace
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/HKUDS/OpenSpace "$T" && mkdir -p ~/.claude/skills && cp -r "$T/showcase/skills/panel-breaker-stateful" ~/.claude/skills/hkuds-openspace-panel-breaker-stateful && rm -rf "$T"
manifest:
showcase/skills/panel-breaker-stateful/SKILL.mdsource content
Resilient Dashboard Panel Pattern
This skill merges the best of
data-service-merged and panel-component-enhanced to create panels that are resilient to API failures while offering enhanced UI features. Each panel manages its own data fetching with built-in circuit breakers and retry logic, and persists its state to localStorage.
Architecture Overview
- Service Module: Each panel has a dedicated service module for data fetching, using circuit breakers for resilience.
- Panel Class: Extends a base
class with built-in loading/error states, retry logic, and state persistence.Panel - Data Flow: Panels automatically fetch data, handle errors with retries, and persist UI state.
Implementation
1. Circuit Breaker Utility with Enhanced Logging
Create
src/utils/circuit-breaker.ts:
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() }; console.log(`[${this.name}] Successfully fetched data`); } 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`); } console.error(`[${this.name}] Failure ${this.state.failures}/${this.maxFailures}:`, error); } 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) { this.recordFailure(String(e)); return defaultValue; } } } export function createCircuitBreaker<T>(options: CircuitBreakerOptions): CircuitBreaker<T> { return new CircuitBreaker<T>(options); }
2. Base Panel Class with Retry Logic and State Persistence
Create
src/components/Panel.ts:
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; private retryAttempts = 0; private maxRetries = 3; private retryDelay = 1000; 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); if (options.showCount) { this.countEl = document.createElement('span'); this.countEl.className = 'panel-count'; this.countEl.textContent = '0'; this.header.appendChild(this.countEl); } 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.loadState(); 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; } protected async fetchWithRetry(url: string): Promise<any> { while (this.retryAttempts < this.maxRetries) { try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (error) { this.retryAttempts++; if (this.retryAttempts >= this.maxRetries) { throw new Error(`Failed after ${this.maxRetries} attempts: ${error.message}`); } await new Promise(resolve => setTimeout(resolve, this.retryDelay)); this.retryDelay *= 2; } } } public saveState(): void { localStorage.setItem(`panelState_${this.panelId}`, JSON.stringify({ isExpanded: !this.element.classList.contains('collapsed'), width: this.element.style.width, height: this.element.style.height })); } public loadState(): void { const savedState = localStorage.getItem(`panelState_${this.panelId}`); if (savedState) { const { isExpanded, width, height } = JSON.parse(savedState); if (!isExpanded) this.element.classList.add('collapsed'); if (width) this.element.style.width = width; if (height) this.element.style.height = height; } } public destroy(): void { this.element.remove(); } }
3. Example: StockPanel with Integrated Service
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); this.saveState(); } catch (err) { this.showError(`Failed to load stock data: ${err.message}`, () => { this.retryAttempts = 0; this.retryDelay = 1000; 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(); } }
Key Patterns
- Resilient Data Fetching: Circuit breakers prevent cascading failures while retry logic handles transient errors.
- State Persistence: Panel state (size, expansion) is saved to localStorage.
- Detailed Error Handling: Users see specific error messages and can retry failed operations.
- Type Safety: Strongly typed interfaces throughout the codebase.