From 15730fdcc6303f512d2d73b56234497f1964909c Mon Sep 17 00:00:00 2001 From: gloskull Date: Fri, 17 Jul 2026 18:23:29 +0100 Subject: [PATCH] Add capacity planning forecast dashboard --- docs/CAPACITY_PLANNING_RUNBOOK.md | 55 ++++++++++++ usage-dashboard/next.config.js | 6 +- usage-dashboard/src/app/layout.tsx | 5 +- usage-dashboard/src/app/page.tsx | 15 +++- .../src/components/CapacityPlanningPanel.tsx | 88 +++++++++++++++++++ usage-dashboard/src/lib/capacityPlanning.ts | 81 +++++++++++++++++ usage-dashboard/src/types/index.ts | 29 ++++++ 7 files changed, 268 insertions(+), 11 deletions(-) create mode 100644 docs/CAPACITY_PLANNING_RUNBOOK.md create mode 100644 usage-dashboard/src/components/CapacityPlanningPanel.tsx create mode 100644 usage-dashboard/src/lib/capacityPlanning.ts diff --git a/docs/CAPACITY_PLANNING_RUNBOOK.md b/docs/CAPACITY_PLANNING_RUNBOOK.md new file mode 100644 index 0000000..f972b5d --- /dev/null +++ b/docs/CAPACITY_PLANNING_RUNBOOK.md @@ -0,0 +1,55 @@ +# Capacity Planning with Historical Usage Trending + +## Architecture + +The usage dashboard now computes capacity forecasts from the same hourly usage samples used by the live dashboard: + +1. Normalize the most recent usage samples into an estimated daily kWh load. +2. Compare the first half of the historical window with the most recent half to derive a growth trend. +3. Apply a configurable safety factor and reserve margin before projecting demand for 30 days. +4. Classify capacity risk as low, medium, high, or critical and expose the result in the dashboard. + +The implementation is intentionally client-side and deterministic for the mock dashboard so the critical path stays under the 100 ms target. Production wiring should replace the mock samples with the metering API while preserving the pure `buildCapacityPlan` interface for testability. + +## Alert Policy + +| Risk | Condition | Action | +| --- | --- | --- | +| Low | < 75% usable capacity and no projected breach | Continue normal monitoring. | +| Medium | >= 75% usable capacity or breach within 30 days | Review provider capacity queue and open planning ticket. | +| High | >= 90% usable capacity or breach within 7 days | Page utility operations and prepare expansion/cutover plan. | +| Critical | >= 100% usable capacity | Start incident response and apply emergency throttling if required. | + +## Monitoring and Dashboards + +Track these fields from `CapacityPlan`: + +- `currentDailyUsageKWh` +- `usableCapacityKWhPerDay` +- `utilizationPercent` +- `dailyGrowthRatePercent` +- `daysUntilCapacityBreach` +- `recommendedCapacityKWhPerDay` +- `riskLevel` + +Recommended SLO panels: + +- P99 dashboard forecast calculation latency, target < 100 ms. +- Capacity risk count by utility provider. +- Canary versus stable forecast delta after deployment. +- Alert delivery success rate, target 99.99% availability. + +## Blue-Green and Canary Deployment + +1. Deploy the new dashboard build to the green environment with forecast alerts disabled. +2. Mirror production meter samples into green and compare forecasts against blue for one hour. +3. Enable canary alerts for 5% of providers and verify alert volume matches expected risk distribution. +4. Increase to 25%, 50%, and 100% if canary forecast deltas stay below 5%. +5. Keep blue warm for rollback until one full daily cycle completes. + +## Security Review Checklist + +- Forecasts must use aggregate usage samples only; do not expose raw wallet secrets or meter keys. +- Alert payloads must omit precise customer location and device MAC addresses. +- Dashboard changes must pass dependency audit and standard repository review before production rollout. +- Treat capacity thresholds as configuration managed by operations, not user-provided input. diff --git a/usage-dashboard/next.config.js b/usage-dashboard/next.config.js index dafb0c8..767719f 100644 --- a/usage-dashboard/next.config.js +++ b/usage-dashboard/next.config.js @@ -1,8 +1,4 @@ /** @type {import('next').NextConfig} */ -const nextConfig = { - experimental: { - appDir: true, - }, -} +const nextConfig = {} module.exports = nextConfig diff --git a/usage-dashboard/src/app/layout.tsx b/usage-dashboard/src/app/layout.tsx index 0a8cfb8..f218dcc 100644 --- a/usage-dashboard/src/app/layout.tsx +++ b/usage-dashboard/src/app/layout.tsx @@ -1,8 +1,5 @@ import './globals.css' import type { Metadata } from 'next' -import { Inter } from 'next/font/google' - -const inter = Inter({ subsets: ['latin'] }) export const metadata: Metadata = { title: 'Utility-Protocol - Usage Dashboard', @@ -16,7 +13,7 @@ export default function RootLayout({ }) { return ( - {children} + {children} ) } diff --git a/usage-dashboard/src/app/page.tsx b/usage-dashboard/src/app/page.tsx index 1beb622..9d79a61 100644 --- a/usage-dashboard/src/app/page.tsx +++ b/usage-dashboard/src/app/page.tsx @@ -5,24 +5,29 @@ import { Zap, DollarSign, TrendingUp, Activity } from 'lucide-react'; import StatsCard from '@/components/StatsCard'; import UsageChart from '@/components/UsageChart'; import MeterInfo from '@/components/MeterInfo'; -import { UsageData, MeterData, DashboardStats } from '@/types'; +import { UsageData, MeterData, DashboardStats, CapacityPlan } from '@/types'; import { generateMockUsageData, generateMockMeterData, calculateStats, updateUsageData } from '@/lib/mockData'; +import { buildCapacityPlan } from '@/lib/capacityPlanning'; +import CapacityPlanningPanel from '@/components/CapacityPlanningPanel'; export default function Home() { const [usageData, setUsageData] = useState([]); const [meterData, setMeterData] = useState(null); const [stats, setStats] = useState(null); const [isRealTime, setIsRealTime] = useState(true); + const [capacityPlan, setCapacityPlan] = useState(null); // Initialize data useEffect(() => { const initialUsageData = generateMockUsageData(); const initialMeterData = generateMockMeterData(); const initialStats = calculateStats(initialUsageData); + const initialCapacityPlan = buildCapacityPlan(initialUsageData); setUsageData(initialUsageData); setMeterData(initialMeterData); setStats(initialStats); + setCapacityPlan(initialCapacityPlan); }, []); // Real-time updates @@ -33,6 +38,7 @@ export default function Home() { setUsageData(prevData => { const newData = updateUsageData(prevData); setStats(calculateStats(newData)); + setCapacityPlan(buildCapacityPlan(newData)); return newData; }); }, 5000); // Update every 5 seconds @@ -40,7 +46,7 @@ export default function Home() { return () => clearInterval(interval); }, [isRealTime]); - if (!stats || !meterData) { + if (!stats || !meterData || !capacityPlan) { return (
@@ -144,6 +150,11 @@ export default function Home() {
+ {/* Capacity planning forecast */} +
+ +
+ {/* Additional Info Section */}
diff --git a/usage-dashboard/src/components/CapacityPlanningPanel.tsx b/usage-dashboard/src/components/CapacityPlanningPanel.tsx new file mode 100644 index 0000000..907d6d6 --- /dev/null +++ b/usage-dashboard/src/components/CapacityPlanningPanel.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { AlertTriangle, BarChart3, CheckCircle2, Gauge, TrendingUp } from 'lucide-react'; +import { CapacityPlan } from '@/types'; + +interface CapacityPlanningPanelProps { + plan: CapacityPlan; +} + +const riskStyles = { + low: 'bg-green-100 text-green-800 border-green-200', + medium: 'bg-yellow-100 text-yellow-800 border-yellow-200', + high: 'bg-orange-100 text-orange-800 border-orange-200', + critical: 'bg-red-100 text-red-800 border-red-200', +}; + +export default function CapacityPlanningPanel({ plan }: CapacityPlanningPanelProps) { + const breachText = plan.daysUntilCapacityBreach === null + ? 'No breach projected in current trend' + : plan.daysUntilCapacityBreach === 0 + ? 'Capacity breach now' + : `${plan.daysUntilCapacityBreach} days until usable capacity breach`; + + const lastProjection = plan.projected[plan.projected.length - 1]; + + return ( +
+
+
+

+ Capacity Planning Forecast +

+

+ Historical usage trend with reserve-margin-aware 30 day projection. +

+
+ + {plan.riskLevel === 'low' ? : } + {plan.riskLevel} risk + +
+ +
+
+ +

Current Utilization

+

{plan.utilizationPercent}%

+

of {plan.usableCapacityKWhPerDay} kWh/day usable

+
+
+ +

Daily Trend

+

{plan.dailyGrowthRatePercent}%

+

safety-factor adjusted

+
+
+ +

Breach Forecast

+

{breachText}

+
+
+ +

Recommended Capacity

+

{plan.recommendedCapacityKWhPerDay}

+

kWh/day for target window

+
+
+ +
+
+ Today: {plan.currentDailyUsageKWh} kWh/day + Day {lastProjection.day}: {lastProjection.projectedKWh} kWh/day +
+
+
+
+

+ Alert policy: medium at 75% utilization or 30 day breach, high at 90% or 7 day breach, + critical at 100%. Use blue-green dashboard rollout and canary alerts before changing capacity limits. +

+
+
+ ); +} diff --git a/usage-dashboard/src/lib/capacityPlanning.ts b/usage-dashboard/src/lib/capacityPlanning.ts new file mode 100644 index 0000000..e799851 --- /dev/null +++ b/usage-dashboard/src/lib/capacityPlanning.ts @@ -0,0 +1,81 @@ +import { CapacityPlan, CapacityPlanningConfig, CapacityPlanningPoint, CapacityRiskLevel, UsageData } from '@/types'; + +const HOURS_PER_DAY = 24; +const DEFAULT_CONFIG: CapacityPlanningConfig = { + installedCapacityKWhPerDay: 18, + reserveMarginPercent: 20, + planningWindowDays: 30, + growthSafetyFactorPercent: 10, +}; + +function round(value: number, digits = 2): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function riskFor(utilizationPercent: number, daysUntilCapacityBreach: number | null): CapacityRiskLevel { + if (utilizationPercent >= 100 || daysUntilCapacityBreach === 0) return 'critical'; + if (utilizationPercent >= 90 || (daysUntilCapacityBreach !== null && daysUntilCapacityBreach <= 7)) return 'high'; + if (utilizationPercent >= 75 || (daysUntilCapacityBreach !== null && daysUntilCapacityBreach <= 30)) return 'medium'; + return 'low'; +} + +export function buildCapacityPlan( + usageData: UsageData[], + config: Partial = {}, +): CapacityPlan { + const planningConfig = { ...DEFAULT_CONFIG, ...config }; + const chronological = [...usageData].sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + const totalKWh = chronological.reduce((sum, point) => sum + point.kWh, 0); + const observedHours = Math.max(chronological.length, 1); + const currentDailyUsageKWh = (totalKWh / observedHours) * HOURS_PER_DAY; + + const midpoint = Math.max(Math.floor(chronological.length / 2), 1); + const early = chronological.slice(0, midpoint); + const recent = chronological.slice(midpoint); + const earlyDaily = (early.reduce((sum, point) => sum + point.kWh, 0) / Math.max(early.length, 1)) * HOURS_PER_DAY; + const recentDaily = (recent.reduce((sum, point) => sum + point.kWh, 0) / Math.max(recent.length, 1)) * HOURS_PER_DAY; + const observedDays = observedHours / HOURS_PER_DAY; + const rawTrend = observedDays > 0 ? ((recentDaily - earlyDaily) / Math.max(earlyDaily, 0.001)) / observedDays : 0; + const dailyGrowthRate = Math.max(0, rawTrend) * (1 + planningConfig.growthSafetyFactorPercent / 100); + + const usableCapacity = planningConfig.installedCapacityKWhPerDay * (1 - planningConfig.reserveMarginPercent / 100); + const utilizationPercent = (currentDailyUsageKWh / Math.max(usableCapacity, 0.001)) * 100; + + let daysUntilCapacityBreach: number | null = null; + if (currentDailyUsageKWh >= usableCapacity) { + daysUntilCapacityBreach = 0; + } else if (dailyGrowthRate > 0) { + daysUntilCapacityBreach = Math.ceil(Math.log(usableCapacity / currentDailyUsageKWh) / Math.log(1 + dailyGrowthRate)); + } + + const projected: CapacityPlanningPoint[] = Array.from({ length: planningConfig.planningWindowDays + 1 }, (_, day) => { + const projectedKWh = currentDailyUsageKWh * (1 + dailyGrowthRate) ** day; + const utilization = (projectedKWh / Math.max(usableCapacity, 0.001)) * 100; + return { + day, + projectedKWh: round(projectedKWh), + utilizationPercent: round(utilization), + riskLevel: riskFor(utilization, daysUntilCapacityBreach !== null ? Math.max(daysUntilCapacityBreach - day, 0) : null), + }; + }); + + const riskLevel = riskFor(utilizationPercent, daysUntilCapacityBreach); + const recommendedCapacityKWhPerDay = currentDailyUsageKWh * (1 + dailyGrowthRate) ** planningConfig.planningWindowDays + / (1 - planningConfig.reserveMarginPercent / 100); + + return { + currentDailyUsageKWh: round(currentDailyUsageKWh), + installedCapacityKWhPerDay: planningConfig.installedCapacityKWhPerDay, + usableCapacityKWhPerDay: round(usableCapacity), + utilizationPercent: round(utilizationPercent), + dailyGrowthRatePercent: round(dailyGrowthRate * 100, 2), + daysUntilCapacityBreach, + recommendedCapacityKWhPerDay: round(recommendedCapacityKWhPerDay), + riskLevel, + projected, + }; +} diff --git a/usage-dashboard/src/types/index.ts b/usage-dashboard/src/types/index.ts index 0496aa9..140db79 100644 --- a/usage-dashboard/src/types/index.ts +++ b/usage-dashboard/src/types/index.ts @@ -26,3 +26,32 @@ export interface DashboardStats { averageDailyUsage: number; averageDailySpend: number; } + + +export type CapacityRiskLevel = 'low' | 'medium' | 'high' | 'critical'; + +export interface CapacityPlanningConfig { + installedCapacityKWhPerDay: number; + reserveMarginPercent: number; + planningWindowDays: number; + growthSafetyFactorPercent: number; +} + +export interface CapacityPlanningPoint { + day: number; + projectedKWh: number; + utilizationPercent: number; + riskLevel: CapacityRiskLevel; +} + +export interface CapacityPlan { + currentDailyUsageKWh: number; + installedCapacityKWhPerDay: number; + usableCapacityKWhPerDay: number; + utilizationPercent: number; + dailyGrowthRatePercent: number; + daysUntilCapacityBreach: number | null; + recommendedCapacityKWhPerDay: number; + riskLevel: CapacityRiskLevel; + projected: CapacityPlanningPoint[]; +}