| name | panel-breaker-backoff |
| description | Unified guide for authoring dashboard panel components in vanilla TypeScript — covers the Panel base class architecture, localStorage state persistence, exponential-backoff retry, and CircuitBreaker integration. Shows how to wrap any existing fetchX() service call with CircuitBreaker.execute() as a one-liner and clarifies when each resilience layer is appropriate. |
Resilient Panel — Unified Pattern
Create dashboard panel components using vanilla TypeScript (no framework, no JSX).
Each panel is a class extending a Panel base class and optionally wraps its data source with a CircuitBreaker.
Architecture Overview
Panel (base class)
├── element: HTMLElement (.panel)
│ ├── header: HTMLElement (.panel-header)
│ │ ├── headerLeft (.panel-header-left)
│ │ │ ├── title (.panel-title)
│ │ │ └── newBadge (.panel-new-badge) [optional]
│ │ ├── statusBadge (.panel-data-badge) [optional]
│ │ └── countEl (.panel-count) [optional]
│ ├── content: HTMLElement (.panel-content)
│ └── resizeHandle (.panel-resize-handle)
Resilience layers (choose what you need)
| Layer | File | When to use |
|---|
| isFetching guard | Panel base class | Always — prevents concurrent duplicate requests |
| Exponential-backoff retry | Panel.fetchWithRetry() | When you own the fetch call and want per-request retry before surfacing an error |
| CircuitBreaker | src/utils/circuit-breaker.ts | When a service may fail repeatedly; stops hammering the API and serves stale cache instead |
| localStorage persistence | Panel.saveState() / loadState() | When you want collapsed/size state to survive page reloads |
Wrapping an existing service call with CircuitBreaker is a one-liner:
const data = await fetchStockQuotes();
const data = await breaker.execute(() => fetchStockQuotes(), []);
File 1 — 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;
protected retryAttempts = 0;
protected retryDelay = 1000;
private maxRetries = 3;
constructor(options: PanelOptions) {
this.panelId = options.id;
this.element = document.createElement();
.. = ;
... = options.;
. = .();
.. = ;
headerLeft = .();
headerLeft. = ;
title = .();
title. = ;
title. = options.;
headerLeft.(title);
..(headerLeft);
(options.) {
. = .();
.. = ;
.. = ;
..(.);
}
. = .();
.. = ;
.. = ;
..(.);
..(.);
.();
}
(): { .; }
(message = ): {
.. = ;
}
(message = , ?: ): {
.. = ;
(onRetry) {
..()
?.(, onRetry);
}
}
(: ): { .. = html; }
(: ): {
(.) .. = count.();
}
(): { ...(); }
(): { ...(); }
(): { ..(); }
(: ): { . = v; }
(): { .; }
(: ): <> {
(. < .) {
{
response = (url);
(!response.) ();
response.();
} (error) {
.++;
(. >= .) {
(
);
}
( (resolve, .));
. *= ;
}
}
}
(): {
.(, .({
: !...(),
: ...,
: ...,
}));
}
(): {
raw = .();
(!raw) ;
{ isExpanded, width, height } = .(raw) {
: ; : ; : ;
};
(!isExpanded) ...();
(width) ... = width;
(height) ... = height;
}
}
File 2 — 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 readonly name: string;
private readonly maxFailures: number;
private readonly cooldownMs: number;
private readonly : ;
() {
. = options.;
. = options. ?? ;
. = options. ?? * * ;
. = options. ?? * * ;
}
(): {
(.() < ..) ;
(.. > ) {
. = { : , : };
}
;
}
(): T | {
(. && .() - .. < .) {
..;
}
;
}
(: T): {
. = { : , : };
. = { data, : .() };
}
(): {
..++;
(.. >= .) {
.. = .() + .;
.();
}
}
execute<R T>(: <R>, : R): <R> {
(.()) {
cached = .();
(cached R) ?? defaultValue;
}
cached = .();
(cached !== ) cached R;
{
result = ();
.(result);
result;
} (e) {
.(, e);
.();
defaultValue;
}
}
}
createCircuitBreaker<T>(
:
): <T> {
<T>(options);
}
File 3 — src/utils/sparkline.ts
export function miniSparkline(
data: number[] | undefined,
change: number | null,
w = 50,
h = 16
): string {
if (!data || data.length < 2) return '';
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const color = change != null && change >= 0 ? 'var(--green)' : 'var(--red)';
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((v - min) / range) * (h - 2) - 1;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg width="" height="" viewBox="0 0 ">` +
+
;
}
Creating a Concrete Panel
Option A — Wrapping an existing project service with CircuitBreaker (preferred)
Use this when the project already has a fetchStockQuotes() (or similar) function.
The circuit breaker is a one-liner wrapper — no refactoring of the service needed.
import { Panel } from './Panel';
import { createCircuitBreaker } from '../utils/circuit-breaker';
import { fetchStockQuotes } from '../services/stocks';
interface StockQuote {
symbol: string;
name: string;
price: number | null;
change: number | null;
sparkline?: number[];
}
const breaker = createCircuitBreaker<StockQuote[]>({
name: 'StockMarket',
cacheTtlMs: 60_000,
cooldownMs: 5 * 60_000,
});
export class StockPanel extends Panel {
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({ id: , : , : });
.();
.();
. = ( .(), );
btn = .();
btn. = ;
btn. = ;
btn.(, .());
..(btn);
}
(): <> {
(.) ;
.();
{
quotes = breaker.( (), []);
.(quotes);
.(quotes.);
.();
} (err) {
.(
,
.()
);
} {
.();
}
}
(: []): {
rows = quotes.( ).();
.();
}
(): {
(.) (.);
.();
}
}
Option B — Direct fetch with fetchWithRetry (no existing service layer)
Use this when there is no project service and you want per-request exponential backoff:
import { Panel } from './Panel';
interface NewsArticle {
id: string;
title: string;
url: string;
}
export class NewsPanel extends Panel {
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({ id: 'news', title: 'Live News', showCount: true });
this.fetchData();
this.refreshTimer = setInterval(() => this.fetchData(), 120_000);
}
private async fetchData(): Promise<void> {
if (this.isFetching) return;
this.setFetching();
{
articles = .() [];
.(articles);
.(articles.);
} (err) {
.(
,
{
. = ;
. = ;
.();
}
);
} {
.();
}
}
(: []): {
items = articles.(
).();
.();
}
(): {
(.) (.);
.();
}
}
Option C — Plain fetch, no resilience layer
For non-critical panels or mock/dev data where retries add no value:
private async fetchData(): Promise<void> {
if (this.isFetching) return;
this.setFetching(true);
try {
const resp = await fetch('/api/config');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
this.render(data);
} catch (err) {
this.showError('Could not load config', () => this.fetchData());
} finally {
this.setFetching(false);
}
}
Decision Guide — Which resilience layer?
Does the project have an existing fetchX() service function?
YES -> wrap it: breaker.execute(() => fetchX(), defaultValue) <- Option A
NO -> does the endpoint fail intermittently under normal load?
YES -> use fetchWithRetry() for per-request retry <- Option B
NO -> plain fetch() with showError/retry is enough <- Option C
Is the endpoint unreliable or rate-limited for extended periods?
YES -> add CircuitBreaker (prevents hammering during outages)
NO -> fetchWithRetry or plain fetch is sufficient
Do you need panel state (size, collapsed) to survive page reloads?
YES -> call this.loadState() in constructor (after super())
call this.saveState() after a successful render
Protected API Reference
All members below are accessible from subclasses without importing or inspecting Panel.ts directly:
| Member / Method | Type | Description |
|---|
element | HTMLElement | Outer container div (.panel) |
header | HTMLElement | Header bar div — append extra controls here |
content | HTMLElement | Content area div (.panel-content) |
countEl | HTMLElement | null | Count badge, or null if showCount not set |
panelId | string | The id from PanelOptions |
retryAttempts | number | Current retry count for fetchWithRetry |
retryDelay | number | Current delay (ms) for fetchWithRetry; reset to 1000 before retry |
isFetching | boolean (getter) | true while an async fetch is in progress |
setFetching(v) | void | Set the fetching guard flag |
showLoading(msg?) | void | Replace content with a loading spinner |
showError(msg?, onRetry?) | void | Replace content with an error state and optional retry button |
setContent(html) | void | Set raw HTML into the content area |
setCount(n) | void | Update the count badge (no-op if countEl is null) |
fetchWithRetry(url) | |
Example — appending a button to the header in a subclass:
constructor() {
super({ id: 'insights', title: 'Insights', className: 'panel-wide' });
const btn = document.createElement('button');
btn.className = 'panel-refresh-btn';
btn.textContent = 'Refresh';
btn.addEventListener('click', () => this.generate());
this.header.appendChild(btn);
}
Key Patterns (checklist)
- Constructor: call
super() with PanelOptions, optionally call loadState(), then trigger initial data fetch.
- fetchData(): async, use
isFetching guard, wrap service calls with breaker.execute() when available.
- render(): build HTML strings, call
this.setContent(html), then this.saveState().
- Error recovery:
showError(message, () => this.fetchData()) — retry button is wired automatically.
- destroy(): clear all
setInterval / setTimeout handles, then call super.destroy().
- Sparklines: import
miniSparkline from src/utils/sparkline.ts, embed return value directly in HTML template strings.
- Header controls:
this.header.appendChild(el) — header is protected, safe to use in subclasses.
localStorage Utilities (optional helpers)
See examples/localStorageUtils.ts for typed wrappers when you need to persist additional per-panel data beyond the built-in size/collapsed state (e.g. user filter selections, last-viewed item).