Skip to content
Open
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
14 changes: 14 additions & 0 deletions apps/pulse-forecast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@pulsestack/pulse-forecast",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./engine": "./src/engine.ts"
},
"dependencies": {
"@pulsestack/core": "workspace:*",
"@pulsestack/contracts": "workspace:*"
}
}
139 changes: 139 additions & 0 deletions apps/pulse-forecast/src/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Infrastructure Capacity Forecast Engine
*
* Analyzes historical resource usage and generates capacity forecasts
* to help teams plan scaling before performance issues occur.
*/

export type ResourceType = 'cpu' | 'memory' | 'disk';

export type ForecastPoint = {
timestamp: string;
value: number;
confidence: number;
};

export type CapacityWarning = {
id: string;
resource: ResourceType;
service: string;
severity: 'low' | 'medium' | 'high' | 'critical';
message: string;
forecastedAt: string;
threshold: number;
predictedValue: number;
};

export type DiskGrowth = {
service: string;
currentGB: number;
forecastedGB: number;
daysUntilFull: number;
growthRateGBPerDay: number;
};

export type ForecastReport = {
cpu: { current: number; forecast7d: number; forecast30d: number; warning?: CapacityWarning };
memory: { current: number; forecast7d: number; forecast30d: number; warning?: CapacityWarning };
disk: { current: number; forecast7d: number; forecast30d: number; growth: DiskGrowth[] };
};

const SERVICES = ['pulse-runtime', 'pulse-gateway', 'pulse-graph', 'pulse-metrics', 'pulse-trace', 'pulse-events'];

function generateHistory(days = 30, base = 50, variance = 20, trend = 0.1): number[] {
const history: number[] = [];
for (let i = days; i >= 0; i--) {
const trendValue = trend * (days - i);
history.push(Math.max(0, Math.min(100, base + (Math.random() - 0.5) * variance + trendValue)));
}
return history;
}

function forecast(history: number[], days: number): number[] {
const last = history[history.length - 1];
const trend = history.length > 1 ? (history[history.length - 1] - history[0]) / history.length : 0;
const forecast: number[] = [];
for (let i = 1; i <= days; i++) {
forecast.push(Math.max(0, Math.min(100, last + trend * i)));
}
return forecast;
}

export class CapacityForecastEngine {
private cpuHistory = generateHistory(30, 45, 15, 0.3);
private memoryHistory = generateHistory(30, 60, 20, 0.2);
private diskHistory: Map<string, number[]> = new Map();
private warnings: CapacityWarning[] = [];

constructor() {
for (const service of SERVICES) {
this.diskHistory.set(service, generateHistory(30, 55, 10, 0.15));
}
}

getForecast(): ForecastReport {
const cpu7 = forecast(this.cpuHistory, 7);
const cpu30 = forecast(this.cpuHistory, 30);
const mem7 = forecast(this.memoryHistory, 7);
const mem30 = forecast(this.memoryHistory, 30);

const diskGrowth = SERVICES.map((service) => {
const history = this.diskHistory.get(service) ?? [];
const current = history[history.length - 1] ?? 50;
const f30 = forecast(history, 30);
const predicted = f30[f30.length - 1];
const daysUntilFull = predicted >= 100 ? Math.max(1, Math.round((100 - current) / Math.max(0.01, predicted - current))) : 999;
return {
service,
currentGB: Math.round(current * 10) / 10,
forecastedGB: Math.round(predicted * 10) / 10,
daysUntilFull,
growthRateGBPerDay: Math.round((predicted - current) / 30 * 100) / 100,
};
}).sort((a, b) => a.daysUntilFull - b.daysUntilFull);

let cpuWarning: CapacityWarning | undefined;
if (cpu30[cpu30.length - 1] > 85) {
cpuWarning = {
id: 'warn-cpu',
resource: 'cpu',
service: 'pulse-gateway',
severity: cpu30[cpu30.length - 1] > 95 ? 'critical' : 'high',
message: `CPU usage forecast to reach ${cpu30[cpu30.length - 1].toFixed(1)}% in 30 days`,
forecastedAt: new Date(Date.now() + 30 * 86400000).toISOString(),
threshold: 85,
predictedValue: Math.round(cpu30[cpu30.length - 1] * 100) / 100,
};
}

let memWarning: CapacityWarning | undefined;
if (mem30[mem30.length - 1] > 80) {
memWarning = {
id: 'warn-mem',
resource: 'memory',
service: 'pulse-runtime',
severity: mem30[mem30.length - 1] > 95 ? 'critical' : 'high',
message: `Memory usage forecast to reach ${mem30[mem30.length - 1].toFixed(1)}% in 30 days`,
forecastedAt: new Date(Date.now() + 30 * 86400000).toISOString(),
threshold: 80,
predictedValue: Math.round(mem30[mem30.length - 1] * 100) / 100,
};
}

this.warnings = [cpuWarning, memWarning].filter((w): w is CapacityWarning => Boolean(w));

return {
cpu: { current: this.cpuHistory[this.cpuHistory.length - 1], forecast7d: cpu7[cpu7.length - 1], forecast30d: cpu30[cpu30.length - 1], warning: cpuWarning },
memory: { current: this.memoryHistory[this.memoryHistory.length - 1], forecast7d: mem7[mem7.length - 1], forecast30d: mem30[mem30.length - 1], warning: memWarning },
disk: { current: diskGrowth[0]?.currentGB ?? 50, forecast7d: diskGrowth[0]?.forecastedGB ?? 50, forecast30d: diskGrowth[0]?.forecastedGB ?? 50, growth: diskGrowth },
};
}

getWarnings(): CapacityWarning[] {
return [...this.warnings];
}

getServices(): string[] {
return [...SERVICES];
}
}
28 changes: 28 additions & 0 deletions apps/pulse-forecast/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createBaseServer, loadEnv, tenantIdFromHeaders } from '@pulsestack/core';
import { CapacityForecastEngine } from './engine.js';

const env = loadEnv();
const app = await createBaseServer('pulse-forecast');
const engine = new CapacityForecastEngine();

app.get('/forecast', async (request: any) => {
const tenantId = tenantIdFromHeaders(request.headers as Record<string, string | string[] | undefined>, env.TENANT_ID);
return engine.getForecast();
});

app.get('/forecast/warnings', async (request: any) => {
const tenantId = tenantIdFromHeaders(request.headers as Record<string, string | string[] | undefined>, env.TENANT_ID);
return engine.getWarnings();
});

app.get('/forecast/disk', async (request: any) => {
const tenantId = tenantIdFromHeaders(request.headers as Record<string, string | string[] | undefined>, env.TENANT_ID);
const forecast = engine.getForecast();
return forecast.disk;
});

app.get('/forecast/services', async () => {
return engine.getServices();
});

await app.listen({ host: '0.0.0.0', port: env.HTTP_PORT });
101 changes: 100 additions & 1 deletion apps/pulse-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
type SnapshotInspection,
type SnapshotTimelineItem,
} from './components/SnapshotDebugger';
import { EnhancedLogExplorer } from './components/EnhancedLogExplorer';
import { CapacityForecastDashboard } from './components/CapacityForecastDashboard';
import { useWorkflowReplay, type WorkflowEvent } from './hooks/useWorkflowReplay';
import { fetchJson, postJson } from './lib/api';
import { useUiStore } from './store/ui';
Expand Down Expand Up @@ -111,7 +113,7 @@ export default function App() {
const setSelectedExecutionId = useUiStore((state) => state.setSelectedExecutionId);
const [liveEvents, setLiveEvents] = useState<string[]>([]);
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('disconnected');
const [activeTab, setActiveTab] = useState<'monitor' | 'replay'>('monitor');
const [activeTab, setActiveTab] = useState<'monitor' | 'replay' | 'forecast'>('monitor');
const [selectedSnapshotSequence, setSelectedSnapshotSequence] = useState<number | null>(null);
const [replayRun, setReplayRun] = useState<ReplayResponse | null>(null);
const [isStartingReplay, setIsStartingReplay] = useState(false);
Expand Down Expand Up @@ -387,6 +389,16 @@ export default function App() {
>
Replay Simulator
</button>
<button
onClick={() => setActiveTab('forecast')}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all duration-200 ${
activeTab === 'forecast'
? 'bg-cyan/20 text-cyan shadow-sm border border-cyan/30'
: 'text-white/60 hover:text-white border border-transparent'
}`}
>
Capacity Forecast
</button>
</div>

<div className="flex items-center gap-2 text-xs font-mono bg-black/25 px-3 py-1.5 rounded-lg border border-white/5">
Expand Down Expand Up @@ -430,6 +442,93 @@ export default function App() {
</div>
</Panel>

<Panel title="Trace Timeline">
{trace.isLoading ? (
<LoadingStack rows={4} minHeight="min-h-[300px]" />
) : trace.isError ? (
<DashboardError
title="Trace spans unavailable"
message={getErrorMessage(trace.error)}
minHeight="min-h-[300px]"
isRetrying={trace.isFetching}
onRetry={() => void trace.refetch()}
/>
) : !trace.data || trace.data.length === 0 ? (
<DashboardEmpty title="No traces recorded" message="Trace spans will appear after instrumentation emits them." minHeight="min-h-[300px]" />
) : (
<div className="space-y-2 max-h-[300px] min-h-[300px] overflow-y-auto pr-1">
{trace.data.map((span) => (
<div key={`${span.span_id}-${span.started_at}`} className="rounded-xl border border-white/10 bg-black/20 p-3 hover:bg-black/30 transition-colors">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-sm">{span.name}</span>
<span className="text-[10px] uppercase font-mono px-2 py-0.5 rounded bg-white/5 text-mint border border-white/5">{span.kind}</span>
</div>
<div className="font-mono text-[10px] text-white/40 mt-1">{span.started_at}</div>
</div>
))}
</div>
)}
</Panel>
</div>
) : activeTab === 'forecast' ? (
<CapacityForecastDashboard />
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-white flex items-center gap-2">
PulseStack Replay Viewer
</h3>
<span className="bg-cyan/15 text-cyan border border-cyan/30 px-3 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider">
Advanced Tier
</span>
</div>

<WorkflowGraph events={MOCK_EVENTS} currentIndex={replayState.currentStepIndex} />
<ReplayScrubber events={MOCK_EVENTS} replayState={replayState} />
<Panel title="Replay Usage">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="font-mono text-xs text-white/50">
{replayRun?.replaySessionId ? `session ${shortId(replayRun.replaySessionId)}` : 'no replay session'}
</div>
<button
type="button"
onClick={() => void startReplay()}
disabled={!selectedExecutionId || isStartingReplay}
className="rounded-lg border border-cyan/30 bg-cyan/10 px-3 py-1.5 text-xs font-semibold text-cyan transition hover:bg-cyan/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{isStartingReplay ? 'Starting...' : 'Run Replay'}
</button>
</div>
<div className="grid gap-3 md:grid-cols-3">
<UsageCard title="Original" usage={replayRun?.originalUsage ?? executionUsage.data?.usage} />
<UsageCard title="Replay" usage={replayRun?.replayUsage} />
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-xs font-bold uppercase tracking-wider text-white/50">Replay Delta</div>
<div className="mt-3 font-mono text-2xl text-white">
{formatNumber(replayRun?.usageComparison?.totalTokensDelta)}
</div>
<div className="text-xs uppercase text-white/50">
{formatCost(replayRun?.usageComparison?.totalCostDelta)}
</div>
</div>
</div>
</Panel>
<SnapshotDebugger
timeline={snapshotTimeline.data}
inspection={selectedSnapshot.data}
selectedSequence={selectedSnapshotSequence}
isLoading={snapshotTimeline.isLoading}
isInspectionLoading={selectedSnapshot.isLoading}
error={snapshotTimeline.error}
onSelectSequence={setSelectedSnapshotSequence}
onRetry={() => void snapshotTimeline.refetch()}
/>
</div>
)}
</div>
</div>
</Panel>

<Panel title="Trace Timeline">
{trace.isLoading ? (
<LoadingStack rows={4} minHeight="min-h-[300px]" />
Expand Down
Loading