foresight-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: foresight-cli
3
+ Version: 0.1.0
4
+ Summary: Predict system resource exhaustion before it happens.
5
+ Author: Rishi Garg
6
+ Project-URL: Homepage, https://github.com/YOUR_USERNAME/foresight
7
+ Project-URL: Repository, https://github.com/YOUR_USERNAME/foresight
8
+ Project-URL: Issues, https://github.com/YOUR_USERNAME/foresight/issues
9
+ Keywords: cli,monitoring,forecasting,devops,time-series,arima,machine-learning,psutil,system-monitoring
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Environment :: Console
16
+ Classifier: Topic :: System :: Monitoring
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Intended Audience :: System Administrators
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: psutil>=5.9.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: typer>=0.9.0
25
+ Requires-Dist: plotext>=5.2.0
26
+ Requires-Dist: pandas>=2.0.0
27
+ Requires-Dist: statsmodels>=0.14.0
File without changes
File without changes
@@ -0,0 +1,478 @@
1
+ import os
2
+ import time
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ from rich import box
7
+ import plotext as plt
8
+
9
+ from foresight.storage import init_db, get_snapshots, get_metric_series, VALID_METRICS
10
+ from foresight.storage import save_snapshot
11
+ from foresight.collector import collect_loop, collect_snapshot
12
+ from foresight.forecaster import (
13
+ forecast_arima,
14
+ forecast_holtwinters,
15
+ forecast_ensemble,
16
+ check_threshold,
17
+ steps_to_human_time,
18
+ parse_horizon,
19
+ )
20
+
21
+
22
+ # ─── App Setup ────────────────────────────────────────────────────────────────
23
+
24
+ app = typer.Typer(
25
+ name="foresight",
26
+ help="Predict system resource exhaustion before it happens.",
27
+ add_completion=False,
28
+ )
29
+
30
+ console = Console()
31
+
32
+ # Smart thresholds based on real computer health standards
33
+ METRIC_THRESHOLDS = {
34
+ "cpu_percent": {"warning": 75.0, "critical": 90.0},
35
+ "ram_percent": {"warning": 80.0, "critical": 90.0},
36
+ "disk_percent": {"warning": 85.0, "critical": 95.0},
37
+ "ram_used_mb": {"warning": 80.0, "critical": 90.0},
38
+ "disk_used_gb": {"warning": 85.0, "critical": 95.0},
39
+ }
40
+
41
+ VALID_MODELS = ("arima", "holtwinters", "ensemble")
42
+
43
+
44
+ # ─── Private Helpers ──────────────────────────────────────────────────────────
45
+
46
+ def _color(value: float, warning: float = 75.0, critical: float = 90.0) -> str:
47
+ """Return Rich color string based on value vs thresholds."""
48
+ if value >= critical:
49
+ return "bold red"
50
+ elif value >= warning:
51
+ return "yellow"
52
+ else:
53
+ return "green"
54
+
55
+
56
+ def _run_forecast(metric: str, steps: int, model: str) -> dict:
57
+ """Run the selected forecast model and return result dict."""
58
+ if model == "arima":
59
+ return forecast_arima(metric=metric, steps=steps)
60
+ elif model == "holtwinters":
61
+ return forecast_holtwinters(metric=metric, steps=steps)
62
+ else:
63
+ return forecast_ensemble(metric=metric, steps=steps)
64
+
65
+
66
+ def _validate_inputs(metric: str, model: str) -> bool:
67
+ """Validate metric and model. Print error and return False if invalid."""
68
+ if metric not in VALID_METRICS:
69
+ console.print(f"[red]Invalid metric '{metric}'.[/red]")
70
+ console.print(f"Valid metrics: {', '.join(sorted(VALID_METRICS))}")
71
+ return False
72
+ if model not in VALID_MODELS:
73
+ console.print(
74
+ f"[red]Invalid model '{model}'. "
75
+ f"Choose: {', '.join(VALID_MODELS)}[/red]"
76
+ )
77
+ return False
78
+ return True
79
+
80
+
81
+ def _status_icon(status: str) -> str:
82
+ return {"critical": "🚨", "warning": "⚠️ ", "ok": "✅"}.get(status, "")
83
+
84
+
85
+ def _status_style(status: str) -> str:
86
+ return {
87
+ "critical": "bold red",
88
+ "warning": "bold yellow",
89
+ "ok": "bold green",
90
+ }.get(status, "white")
91
+
92
+
93
+ # ─── Commands ─────────────────────────────────────────────────────────────────
94
+
95
+ @app.command()
96
+ def collect(
97
+ interval: int = typer.Option(
98
+ 60, help="Seconds between snapshots (default: 60s)."
99
+ ),
100
+ rounds: int = typer.Option(
101
+ 60, help="Number of snapshots (default: 60 = 1 hour of data)."
102
+ ),
103
+ ) -> None:
104
+ """Collect system metrics every N seconds for M rounds."""
105
+ init_db()
106
+ total_minutes = rounds * interval // 60
107
+ console.print(
108
+ f"\n[bold green]Starting collection:[/bold green] "
109
+ f"{rounds} snapshots every {interval}s "
110
+ f"([cyan]~{total_minutes} minutes total[/cyan])\n"
111
+ )
112
+ collect_loop(interval_seconds=interval, rounds=rounds)
113
+ console.print(
114
+ f"\n[bold green]Done.[/bold green] "
115
+ f"{rounds} snapshots saved to database.\n"
116
+ )
117
+
118
+
119
+ @app.command()
120
+ def status() -> None:
121
+ """Show a single live snapshot of current resource usage."""
122
+ init_db()
123
+ snap = collect_snapshot()
124
+ save_snapshot(snap)
125
+
126
+ cpu = snap["cpu_percent"]
127
+ ram = snap["ram_percent"]
128
+ disk = snap["disk_percent"]
129
+
130
+ cpu_thresh = METRIC_THRESHOLDS["cpu_percent"]
131
+ ram_thresh = METRIC_THRESHOLDS["ram_percent"]
132
+ disk_thresh = METRIC_THRESHOLDS["disk_percent"]
133
+
134
+ console.print("\n[bold]Current System Status[/bold]\n")
135
+ console.print(
136
+ f" CPU : [{_color(cpu, **cpu_thresh)}]{cpu:>5.1f}%[/{_color(cpu, **cpu_thresh)}]"
137
+ )
138
+ console.print(
139
+ f" RAM : [{_color(ram, **ram_thresh)}]{ram:>5.1f}%[/{_color(ram, **ram_thresh)}]"
140
+ f" ({snap['ram_used_mb']:.0f} MB / {snap['ram_total_mb']:.0f} MB)"
141
+ )
142
+ console.print(
143
+ f" Disk : [{_color(disk, **disk_thresh)}]{disk:>5.1f}%[/{_color(disk, **disk_thresh)}]"
144
+ f" ({snap['disk_used_gb']:.1f} GB / {snap['disk_total_gb']:.1f} GB)"
145
+ )
146
+ console.print(f"\n [dim]Saved at {snap['timestamp']}[/dim]\n")
147
+
148
+
149
+ @app.command()
150
+ def watch(
151
+ interval: int = typer.Option(5, help="Refresh every N seconds."),
152
+ rounds: int = typer.Option(50, help="Stop after N refreshes."),
153
+ ) -> None:
154
+ """Live monitor — refreshes resource usage every N seconds. Ctrl+C to stop."""
155
+ init_db()
156
+ console.print(
157
+ f"\n[bold]Foresight Live Monitor[/bold] — "
158
+ f"refreshing every {interval}s. "
159
+ f"[dim]Ctrl+C to stop.[/dim]\n"
160
+ )
161
+
162
+ try:
163
+ for i in range(rounds):
164
+ snap = collect_snapshot()
165
+ save_snapshot(snap)
166
+
167
+ os.system("cls")
168
+
169
+ cpu = snap["cpu_percent"]
170
+ ram = snap["ram_percent"]
171
+ disk = snap["disk_percent"]
172
+
173
+ cpu_thresh = METRIC_THRESHOLDS["cpu_percent"]
174
+ ram_thresh = METRIC_THRESHOLDS["ram_percent"]
175
+ disk_thresh = METRIC_THRESHOLDS["disk_percent"]
176
+
177
+ console.print(
178
+ f"[bold]Foresight Live Monitor[/bold] "
179
+ f"[dim]{snap['timestamp']}[/dim]\n"
180
+ )
181
+
182
+ cpu_bar = "█" * int(cpu / 5)
183
+ ram_bar = "█" * int(ram / 5)
184
+ disk_bar = "█" * int(disk / 5)
185
+
186
+ console.print(
187
+ f" CPU : [{_color(cpu, **cpu_thresh)}]{cpu:>5.1f}%[/{_color(cpu, **cpu_thresh)}] {cpu_bar}"
188
+ )
189
+ console.print(
190
+ f" RAM : [{_color(ram, **ram_thresh)}]{ram:>5.1f}%[/{_color(ram, **ram_thresh)}] {ram_bar}"
191
+ f" ({snap['ram_used_mb']:.0f} MB / {snap['ram_total_mb']:.0f} MB)"
192
+ )
193
+ console.print(
194
+ f" Disk : [{_color(disk, **disk_thresh)}]{disk:>5.1f}%[/{_color(disk, **disk_thresh)}] {disk_bar}"
195
+ f" ({snap['disk_used_gb']:.1f} GB / {snap['disk_total_gb']:.1f} GB)"
196
+ )
197
+ console.print(
198
+ f"\n [dim]Refresh {i+1}/{rounds} — next in {interval}s[/dim]"
199
+ )
200
+
201
+ if i < rounds - 1:
202
+ time.sleep(interval)
203
+
204
+ except KeyboardInterrupt:
205
+ console.print("\n[yellow]Live monitor stopped.[/yellow]\n")
206
+
207
+
208
+ @app.command()
209
+ def show(
210
+ limit: int = typer.Option(10, help="Number of recent snapshots to display."),
211
+ ) -> None:
212
+ """Show recent snapshots from the database as a color-coded table."""
213
+ init_db()
214
+ snapshots = get_snapshots(limit=limit)
215
+
216
+ if not snapshots:
217
+ console.print(
218
+ "[yellow]No snapshots found. "
219
+ "Run 'foresight collect' first.[/yellow]"
220
+ )
221
+ raise typer.Exit()
222
+
223
+ table = Table(
224
+ title=f"Last {len(snapshots)} Snapshots",
225
+ box=box.ROUNDED,
226
+ show_lines=True,
227
+ )
228
+
229
+ table.add_column("Timestamp", style="cyan", no_wrap=True)
230
+ table.add_column("CPU %", justify="right")
231
+ table.add_column("RAM %", justify="right")
232
+ table.add_column("RAM Used (MB)", justify="right")
233
+ table.add_column("Disk %", justify="right")
234
+ table.add_column("Disk Used (GB)", justify="right")
235
+
236
+ cpu_t = METRIC_THRESHOLDS["cpu_percent"]
237
+ ram_t = METRIC_THRESHOLDS["ram_percent"]
238
+ disk_t = METRIC_THRESHOLDS["disk_percent"]
239
+
240
+ for snap in reversed(snapshots):
241
+ cpu = snap["cpu_percent"]
242
+ ram = snap["ram_percent"]
243
+ disk = snap["disk_percent"]
244
+
245
+ table.add_row(
246
+ snap["timestamp"],
247
+ f"[{_color(cpu, **cpu_t)}]{cpu}%[/{_color(cpu, **cpu_t)}]",
248
+ f"[{_color(ram, **ram_t)}]{ram}%[/{_color(ram, **ram_t)}]",
249
+ str(snap["ram_used_mb"]),
250
+ f"[{_color(disk, **disk_t)}]{disk}%[/{_color(disk, **disk_t)}]",
251
+ str(snap["disk_used_gb"]),
252
+ )
253
+
254
+ console.print(table)
255
+
256
+
257
+ @app.command()
258
+ def chart(
259
+ metric: str = typer.Option("cpu_percent", help="Metric to chart."),
260
+ limit: int = typer.Option(50, help="Number of recent snapshots to plot."),
261
+ ) -> None:
262
+ """Render an ASCII line chart of a metric over time in the terminal."""
263
+ init_db()
264
+
265
+ if metric not in VALID_METRICS:
266
+ console.print(f"[red]Invalid metric '{metric}'.[/red]")
267
+ console.print(f"Valid options: {', '.join(sorted(VALID_METRICS))}")
268
+ raise typer.Exit()
269
+
270
+ timestamps, values = get_metric_series(metric=metric, limit=limit)
271
+
272
+ if len(values) < 2:
273
+ console.print(
274
+ "[yellow]Not enough data to chart. "
275
+ "Run 'foresight collect' first.[/yellow]"
276
+ )
277
+ raise typer.Exit()
278
+
279
+ short_labels = [ts[11:16] for ts in timestamps]
280
+ x_values = list(range(len(values)))
281
+
282
+ plt.clear_figure()
283
+ plt.plot(x_values, values, label=metric, marker="braille")
284
+ plt.title(
285
+ f"{metric} — {short_labels[0]} to {short_labels[-1]} "
286
+ f"({len(values)} snapshots)"
287
+ )
288
+ plt.xlabel("Snapshot index")
289
+ plt.ylabel("Percent (%)")
290
+ plt.ylim(0, 100)
291
+ plt.plotsize(80, 20)
292
+ plt.show()
293
+
294
+
295
+ @app.command()
296
+ def forecast(
297
+ metric: str = typer.Option("cpu_percent", help="Metric to forecast."),
298
+ horizon: str = typer.Option("30m", help="How far ahead: '30m', '1h', '2h'."),
299
+ model: str = typer.Option("ensemble", help="Model: arima, holtwinters, ensemble."),
300
+ interval: int = typer.Option(60, help="Your collection interval in seconds."),
301
+ ) -> None:
302
+ """Forecast future resource usage over a time horizon (e.g. 30m, 1h, 2h)."""
303
+ init_db()
304
+
305
+ if not _validate_inputs(metric, model):
306
+ raise typer.Exit()
307
+
308
+ try:
309
+ steps = parse_horizon(horizon, interval_seconds=interval)
310
+ except ValueError as e:
311
+ console.print(f"[red]{e}[/red]")
312
+ raise typer.Exit()
313
+
314
+ console.print(
315
+ f"\n[bold]Forecasting[/bold] [cyan]{metric}[/cyan] "
316
+ f"for next [cyan]{horizon}[/cyan] "
317
+ f"({steps} steps × {interval}s) "
318
+ f"using [cyan]{model.upper()}[/cyan]\n"
319
+ )
320
+
321
+ result = _run_forecast(metric, steps, model)
322
+
323
+ trend_color = {"rising": "red", "falling": "green", "stable": "yellow"}.get(
324
+ result["trend_summary"], "white"
325
+ )
326
+
327
+ console.print(f" Last observed : {result['last_observed']}%")
328
+ console.print(
329
+ f" Trend : "
330
+ f"[{trend_color}]{result['trend_summary']}[/{trend_color}]"
331
+ )
332
+
333
+ if "components" in result:
334
+ console.print(
335
+ f"\n [dim]ARIMA : {result['components']['arima']}[/dim]"
336
+ )
337
+ console.print(
338
+ f" [dim]Holt-Winters : {result['components']['holtwinters']}[/dim]"
339
+ )
340
+
341
+ thresh = METRIC_THRESHOLDS.get(metric, {"warning": 75.0, "critical": 90.0})
342
+
343
+ console.print(f"\n {'Time':>10} {'Value':>8} Chart")
344
+ console.print(f" {'─'*10} {'─'*8} {'─'*20}")
345
+
346
+ for i, val in enumerate(result["forecast"], start=1):
347
+ bar = "█" * int(val / 5)
348
+ time_label = steps_to_human_time(i, interval_seconds=interval)
349
+ col = _color(val, **thresh)
350
+ console.print(
351
+ f" {time_label:>10} : [{col}]{val:>6.2f}%[/{col}] {bar}"
352
+ )
353
+
354
+ console.print()
355
+
356
+
357
+ @app.command()
358
+ def alert(
359
+ metric: str = typer.Option("cpu_percent", help="Metric to check."),
360
+ horizon: str = typer.Option("30m", help="How far ahead: '30m', '1h', '2h'."),
361
+ threshold: float = typer.Option(-1.0, help="Custom threshold %. Uses smart default if omitted."),
362
+ model: str = typer.Option("ensemble", help="Model: arima, holtwinters, ensemble."),
363
+ interval: int = typer.Option(60, help="Your collection interval in seconds."),
364
+ ) -> None:
365
+ """Check if a metric is predicted to breach its health threshold."""
366
+ init_db()
367
+
368
+ if not _validate_inputs(metric, model):
369
+ raise typer.Exit()
370
+
371
+ try:
372
+ steps = parse_horizon(horizon, interval_seconds=interval)
373
+ except ValueError as e:
374
+ console.print(f"[red]{e}[/red]")
375
+ raise typer.Exit()
376
+
377
+ # Apply smart default threshold if user didn't provide one
378
+ if threshold == -1.0:
379
+ threshold = METRIC_THRESHOLDS.get(
380
+ metric, {"warning": 85.0}
381
+ )["warning"]
382
+ console.print(
383
+ f"\n [dim]Using smart default threshold: "
384
+ f"{threshold}% for {metric}[/dim]"
385
+ )
386
+
387
+ result = _run_forecast(metric, steps, model)
388
+ alert_result = check_threshold(forecast_result=result, threshold=threshold)
389
+
390
+ status = alert_result["status"]
391
+ icon = _status_icon(status)
392
+ style = _status_style(status)
393
+
394
+ console.print(
395
+ f"\n {icon} [{style}]{status.upper()}[/{style}]"
396
+ f" — {alert_result['message']}\n"
397
+ )
398
+
399
+ if alert_result["breaches"]:
400
+ console.print(f" [dim]Predicted breaches above {threshold}%:[/dim]")
401
+ for b in alert_result["breaches"]:
402
+ time_label = steps_to_human_time(b["step"], interval_seconds=interval)
403
+ console.print(
404
+ f" {time_label:>10} : [red]{b['value']}%[/red]"
405
+ )
406
+ console.print()
407
+
408
+
409
+ @app.command()
410
+ def healthcheck(
411
+ horizon: str = typer.Option("30m", help="How far ahead to check: '30m', '1h', '2h'."),
412
+ model: str = typer.Option("ensemble", help="Model: arima, holtwinters, ensemble."),
413
+ interval: int = typer.Option(60, help="Your collection interval in seconds."),
414
+ ) -> None:
415
+ """Run a full health check across CPU, RAM, and Disk at once."""
416
+ init_db()
417
+
418
+ if model not in VALID_MODELS:
419
+ console.print(f"[red]Invalid model. Choose: {', '.join(VALID_MODELS)}[/red]")
420
+ raise typer.Exit()
421
+
422
+ try:
423
+ steps = parse_horizon(horizon, interval_seconds=interval)
424
+ except ValueError as e:
425
+ console.print(f"[red]{e}[/red]")
426
+ raise typer.Exit()
427
+
428
+ metrics_to_check = [
429
+ ("cpu_percent", METRIC_THRESHOLDS["cpu_percent"]["warning"]),
430
+ ("ram_percent", METRIC_THRESHOLDS["ram_percent"]["warning"]),
431
+ ("disk_percent", METRIC_THRESHOLDS["disk_percent"]["warning"]),
432
+ ]
433
+
434
+ console.print(
435
+ f"\n[bold]System Health Check[/bold] — "
436
+ f"next [cyan]{horizon}[/cyan] "
437
+ f"via [cyan]{model.upper()}[/cyan]\n"
438
+ )
439
+
440
+ overall_status = "ok"
441
+
442
+ for metric, threshold in metrics_to_check:
443
+ result = _run_forecast(metric, steps, model)
444
+ alert_result = check_threshold(forecast_result=result, threshold=threshold)
445
+ status = alert_result["status"]
446
+
447
+ # Escalate overall only upward: ok → warning → critical
448
+ if status == "critical":
449
+ overall_status = "critical"
450
+ elif status == "warning" and overall_status == "ok":
451
+ overall_status = "warning"
452
+
453
+ icon = _status_icon(status)
454
+ style = _status_style(status)
455
+ col = _color(
456
+ result["last_observed"],
457
+ **METRIC_THRESHOLDS.get(metric, {"warning": 75.0, "critical": 90.0}),
458
+ )
459
+
460
+ console.print(
461
+ f" {icon} [cyan]{metric:<20}[/cyan] "
462
+ f"[{style}]{status.upper():<10}[/{style}] "
463
+ f"now: [{col}]{result['last_observed']}%[/{col}] "
464
+ f"threshold: {threshold}% "
465
+ f"trend: {result['trend_summary']}"
466
+ )
467
+
468
+ overall_icon = _status_icon(overall_status)
469
+ overall_style = _status_style(overall_status)
470
+
471
+ console.print(
472
+ f"\n {overall_icon} "
473
+ f"Overall: [{overall_style}]{overall_status.upper()}[/{overall_style}]\n"
474
+ )
475
+
476
+
477
+ if __name__ == "__main__":
478
+ app()
@@ -0,0 +1,50 @@
1
+ import psutil
2
+ from datetime import datetime
3
+ import time
4
+ from foresight.storage import save_snapshot
5
+
6
+
7
+ def collect_snapshot() -> dict:
8
+ cpu = psutil.cpu_percent(interval=1)
9
+
10
+ ram = psutil.virtual_memory()
11
+ ram_used_percent = ram.percent
12
+ ram_used_mb = ram.used / (1024 ** 2)
13
+ ram_total_mb = ram.total / (1024 ** 2)
14
+
15
+ disk = psutil.disk_usage("/")
16
+ disk_used_percent = disk.percent
17
+ disk_used_gb = disk.used / (1024 ** 3)
18
+ disk_total_gb = disk.total / (1024 ** 3)
19
+
20
+ timestamp = datetime.now().isoformat()
21
+
22
+ return {
23
+ "timestamp": timestamp,
24
+ "cpu_percent": cpu,
25
+ "ram_percent": ram_used_percent,
26
+ "ram_used_mb": round(ram_used_mb, 2),
27
+ "ram_total_mb": round(ram_total_mb, 2),
28
+ "disk_percent": disk_used_percent,
29
+ "disk_used_gb": round(disk_used_gb, 2),
30
+ "disk_total_gb": round(disk_total_gb, 2),
31
+ }
32
+
33
+
34
+ def collect_loop(interval_seconds: int = 60, rounds: int = 60) -> None:
35
+ print(f"Collecting every {interval_seconds}s for {rounds} rounds...\n")
36
+ for i in range(rounds):
37
+ snapshot = collect_snapshot()
38
+ save_snapshot(snapshot)
39
+ print(f"[Round {i+1}] {snapshot['timestamp']}")
40
+ print(f" CPU : {snapshot['cpu_percent']}%")
41
+ print(f" RAM : {snapshot['ram_percent']}% ({snapshot['ram_used_mb']} MB used)")
42
+ print(f" Disk : {snapshot['disk_percent']}% ({snapshot['disk_used_gb']} GB used)\n")
43
+ if i < rounds - 1:
44
+ time.sleep(interval_seconds)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ from foresight.storage import init_db
49
+ init_db()
50
+ collect_loop(interval_seconds=5, rounds=3)
@@ -0,0 +1,245 @@
1
+ import warnings
2
+ import pandas as pd
3
+ from statsmodels.tsa.arima.model import ARIMA
4
+ from statsmodels.tsa.holtwinters import ExponentialSmoothing
5
+ from foresight.storage import get_metric_series, VALID_METRICS
6
+
7
+ warnings.filterwarnings("ignore")
8
+
9
+ MINIMUM_DATA_POINTS = 20
10
+
11
+
12
+ # ─── Private Helpers ──────────────────────────────────────────────────────────
13
+
14
+ def _validate_metric(metric: str) -> None:
15
+ if metric not in VALID_METRICS:
16
+ raise ValueError(
17
+ f"Invalid metric '{metric}'. "
18
+ f"Choose from: {VALID_METRICS}"
19
+ )
20
+
21
+
22
+ def _load_series(metric: str, limit: int) -> pd.Series:
23
+ timestamps, values = get_metric_series(metric=metric, limit=limit)
24
+
25
+ if len(values) < MINIMUM_DATA_POINTS:
26
+ raise ValueError(
27
+ f"Not enough data. Need at least {MINIMUM_DATA_POINTS} "
28
+ f"snapshots, got {len(values)}. "
29
+ f"Run 'foresight collect' to gather more data."
30
+ )
31
+ # Soft warning — good forecast needs 3x the requested limit
32
+ if len(values) < limit * 0.5:
33
+ import warnings
34
+ warnings.warn(
35
+ f"Only {len(values)} snapshots available. "
36
+ f"Forecasts improve significantly with more data. "
37
+ f"Run 'foresight collect --rounds 60' for best results.",
38
+ UserWarning,
39
+ stacklevel=2,
40
+ )
41
+
42
+ return pd.Series(values)
43
+
44
+
45
+ def _describe_trend(forecast_values: list[float]) -> str:
46
+ first = forecast_values[0]
47
+ last = forecast_values[-1]
48
+ delta = last - first
49
+
50
+ if delta > 5:
51
+ return "rising"
52
+ elif delta < -5:
53
+ return "falling"
54
+ else:
55
+ return "stable"
56
+
57
+
58
+ # ─── Forecasting Models ───────────────────────────────────────────────────────
59
+
60
+ def forecast_arima(
61
+ metric: str = "cpu_percent",
62
+ steps: int = 10,
63
+ limit: int = 100,
64
+ order: tuple = (2, 1, 1),
65
+ ) -> dict:
66
+ _validate_metric(metric)
67
+ series = _load_series(metric=metric, limit=limit)
68
+
69
+ model = ARIMA(series, order=order)
70
+ fitted = model.fit()
71
+
72
+ forecast_result = fitted.forecast(steps=steps)
73
+ forecast_values = [round(float(v), 2) for v in forecast_result]
74
+ forecast_values = [max(0.0, min(100.0, v)) for v in forecast_values]
75
+
76
+ return {
77
+ "metric": metric,
78
+ "model": "ARIMA",
79
+ "order": order,
80
+ "last_observed": round(float(series.iloc[-1]), 2),
81
+ "steps_ahead": steps,
82
+ "forecast": forecast_values,
83
+ "trend_summary": _describe_trend(forecast_values),
84
+ }
85
+
86
+
87
+ def forecast_holtwinters(
88
+ metric: str = "cpu_percent",
89
+ steps: int = 10,
90
+ limit: int = 100,
91
+ ) -> dict:
92
+ _validate_metric(metric)
93
+ series = _load_series(metric=metric, limit=limit)
94
+
95
+ model = ExponentialSmoothing(
96
+ series,
97
+ trend="add",
98
+ seasonal=None,
99
+ initialization_method="estimated",
100
+ )
101
+
102
+ fitted = model.fit(optimized=True)
103
+ forecast_result = fitted.forecast(steps=steps)
104
+
105
+ forecast_values = [
106
+ round(max(0.0, min(100.0, float(v))), 2)
107
+ for v in forecast_result
108
+ ]
109
+
110
+ return {
111
+ "metric": metric,
112
+ "model": "Holt-Winters",
113
+ "order": None,
114
+ "last_observed": round(float(series.iloc[-1]), 2),
115
+ "steps_ahead": steps,
116
+ "forecast": forecast_values,
117
+ "trend_summary": _describe_trend(forecast_values),
118
+ }
119
+
120
+
121
+ def forecast_ensemble(
122
+ metric: str = "cpu_percent",
123
+ steps: int = 10,
124
+ limit: int = 100,
125
+ ) -> dict:
126
+ _validate_metric(metric)
127
+
128
+ arima_result = forecast_arima(metric=metric, steps=steps, limit=limit)
129
+ hw_result = forecast_holtwinters(metric=metric, steps=steps, limit=limit)
130
+
131
+ blended = [
132
+ round(max(0.0, min(100.0, (a + b) / 2)), 2)
133
+ for a, b in zip(arima_result["forecast"], hw_result["forecast"])
134
+ ]
135
+
136
+ return {
137
+ "metric": metric,
138
+ "model": "Ensemble (ARIMA + Holt-Winters)",
139
+ "order": None,
140
+ "last_observed": arima_result["last_observed"],
141
+ "steps_ahead": steps,
142
+ "forecast": blended,
143
+ "trend_summary": _describe_trend(blended),
144
+ "components": {
145
+ "arima": arima_result["forecast"],
146
+ "holtwinters": hw_result["forecast"],
147
+ },
148
+ }
149
+
150
+
151
+ # ─── Alerting ─────────────────────────────────────────────────────────────────
152
+
153
+ def check_threshold(
154
+ forecast_result: dict,
155
+ threshold: float = 85.0,
156
+ ) -> dict:
157
+ forecast_values = forecast_result["forecast"]
158
+ metric = forecast_result["metric"]
159
+ last_observed = forecast_result["last_observed"]
160
+
161
+ breaches = [
162
+ {"step": i + 1, "value": v}
163
+ for i, v in enumerate(forecast_values)
164
+ if v >= threshold
165
+ ]
166
+
167
+ already_critical = last_observed >= threshold
168
+
169
+ if already_critical:
170
+ status = "critical"
171
+ message = (
172
+ f"{metric} is ALREADY at {last_observed}% — "
173
+ f"above threshold of {threshold}%"
174
+ )
175
+ elif breaches:
176
+ first_breach = breaches[0]
177
+ status = "warning"
178
+ message = (
179
+ f"{metric} predicted to reach {first_breach['value']}% "
180
+ f"in {steps_to_human_time(first_breach['step'])} "
181
+ f"(threshold: {threshold}%)"
182
+ )
183
+ else:
184
+ status = "ok"
185
+ message = (
186
+ f"{metric} looks safe — "
187
+ f"no breach predicted within {len(forecast_values)} steps "
188
+ f"(threshold: {threshold}%)"
189
+ )
190
+
191
+ return {
192
+ "metric": metric,
193
+ "threshold": threshold,
194
+ "status": status,
195
+ "message": message,
196
+ "breaches": breaches,
197
+ "already_critical": already_critical,
198
+ }
199
+
200
+
201
+ # ─── Time Utilities ───────────────────────────────────────────────────────────
202
+
203
+ def steps_to_human_time(step: int, interval_seconds: int = 60) -> str:
204
+ total_seconds = step * interval_seconds
205
+ total_minutes = total_seconds // 60
206
+
207
+ if total_minutes < 1:
208
+ return f"~{total_seconds}s"
209
+ elif total_minutes < 60:
210
+ return f"~{total_minutes}m"
211
+ else:
212
+ hours = total_minutes // 60
213
+ minutes = total_minutes % 60
214
+ if minutes == 0:
215
+ return f"~{hours}h"
216
+ return f"~{hours}h {minutes}m"
217
+
218
+
219
+ def parse_horizon(horizon: str, interval_seconds: int = 60) -> int:
220
+ horizon = horizon.strip().lower()
221
+
222
+ if horizon.endswith("h"):
223
+ hours = float(horizon[:-1])
224
+ total_seconds = hours * 3600
225
+ elif horizon.endswith("m"):
226
+ minutes = float(horizon[:-1])
227
+ total_seconds = minutes * 60
228
+ else:
229
+ raise ValueError(
230
+ f"Invalid horizon '{horizon}'. "
231
+ f"Use format: '30m' for 30 minutes or '2h' for 2 hours."
232
+ )
233
+
234
+ steps = max(1, int(total_seconds // interval_seconds))
235
+ return steps
236
+
237
+
238
+ if __name__ == "__main__":
239
+ result = forecast_arima(metric="cpu_percent", steps=10)
240
+ print(f"\nMetric : {result['metric']}")
241
+ print(f"Model : {result['model']} {result['order']}")
242
+ print(f"Last seen: {result['last_observed']}%")
243
+ print(f"Trend : {result['trend_summary']}")
244
+ print(f"Forecast : {result['forecast']}")
245
+
@@ -0,0 +1,137 @@
1
+ import sqlite3
2
+ from pathlib import Path
3
+
4
+
5
+ DB_DIR = Path(__file__).parent.parent / "data"
6
+ DB_PATH = DB_DIR / "foresight.db"
7
+
8
+ VALID_METRICS = {
9
+ "cpu_percent",
10
+ "ram_percent",
11
+ "ram_used_mb",
12
+ "disk_percent",
13
+ "disk_used_gb",
14
+ }
15
+
16
+
17
+ def init_db() -> None:
18
+ DB_DIR.mkdir(parents=True, exist_ok=True)
19
+
20
+ conn = sqlite3.connect(DB_PATH)
21
+ cursor = conn.cursor()
22
+
23
+ cursor.execute("""
24
+ CREATE TABLE IF NOT EXISTS snapshots (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ timestamp TEXT NOT NULL,
27
+ cpu_percent REAL NOT NULL,
28
+ ram_percent REAL NOT NULL,
29
+ ram_used_mb REAL NOT NULL,
30
+ ram_total_mb REAL NOT NULL,
31
+ disk_percent REAL NOT NULL,
32
+ disk_used_gb REAL NOT NULL,
33
+ disk_total_gb REAL NOT NULL
34
+ )
35
+ """)
36
+
37
+ conn.commit()
38
+ conn.close()
39
+
40
+
41
+ def save_snapshot(snapshot: dict) -> None:
42
+ conn = sqlite3.connect(DB_PATH)
43
+ cursor = conn.cursor()
44
+
45
+ cursor.execute("""
46
+ INSERT INTO snapshots (
47
+ timestamp,
48
+ cpu_percent,
49
+ ram_percent,
50
+ ram_used_mb,
51
+ ram_total_mb,
52
+ disk_percent,
53
+ disk_used_gb,
54
+ disk_total_gb
55
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
56
+ """, (
57
+ snapshot["timestamp"],
58
+ snapshot["cpu_percent"],
59
+ snapshot["ram_percent"],
60
+ snapshot["ram_used_mb"],
61
+ snapshot["ram_total_mb"],
62
+ snapshot["disk_percent"],
63
+ snapshot["disk_used_gb"],
64
+ snapshot["disk_total_gb"],
65
+ ))
66
+
67
+ conn.commit()
68
+ conn.close()
69
+
70
+
71
+ def get_snapshots(limit: int = 100) -> list[dict]:
72
+ conn = sqlite3.connect(DB_PATH)
73
+ conn.row_factory = sqlite3.Row
74
+ cursor = conn.cursor()
75
+
76
+ cursor.execute("""
77
+ SELECT * FROM snapshots
78
+ ORDER BY timestamp DESC
79
+ LIMIT ?
80
+ """, (limit,))
81
+
82
+ rows = cursor.fetchall()
83
+ conn.close()
84
+
85
+ return [dict(row) for row in rows]
86
+
87
+
88
+ def get_metric_series(metric: str, limit: int = 50) -> tuple[list, list]:
89
+ if metric not in VALID_METRICS:
90
+ raise ValueError(
91
+ f"Invalid metric '{metric}'. "
92
+ f"Choose from: {VALID_METRICS}"
93
+ )
94
+
95
+ conn = sqlite3.connect(DB_PATH)
96
+ conn.row_factory = sqlite3.Row
97
+ cursor = conn.cursor()
98
+
99
+ cursor.execute(f"""
100
+ SELECT timestamp, {metric}
101
+ FROM snapshots
102
+ ORDER BY timestamp DESC
103
+ LIMIT ?
104
+ """, (limit,))
105
+
106
+ rows = cursor.fetchall()
107
+ conn.close()
108
+
109
+ rows = list(reversed(rows))
110
+ timestamps = [row["timestamp"] for row in rows]
111
+ values = [row[metric] for row in rows]
112
+
113
+ return timestamps, values
114
+
115
+
116
+ if __name__ == "__main__":
117
+ init_db()
118
+ print(f"Database initialized at: {DB_PATH}")
119
+
120
+ dummy = {
121
+ "timestamp": "2026-03-22T10:00:00",
122
+ "cpu_percent": 45.2,
123
+ "ram_percent": 61.0,
124
+ "ram_used_mb": 9800.0,
125
+ "ram_total_mb": 16384.0,
126
+ "disk_percent": 43.0,
127
+ "disk_used_gb": 430.0,
128
+ "disk_total_gb": 953.0,
129
+ }
130
+
131
+ save_snapshot(dummy)
132
+ print("Saved one dummy snapshot.")
133
+
134
+ results = get_snapshots(limit=5)
135
+ print(f"\nLast {len(results)} snapshots:")
136
+ for row in results:
137
+ print(row)
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: foresight-cli
3
+ Version: 0.1.0
4
+ Summary: Predict system resource exhaustion before it happens.
5
+ Author: Rishi Garg
6
+ Project-URL: Homepage, https://github.com/YOUR_USERNAME/foresight
7
+ Project-URL: Repository, https://github.com/YOUR_USERNAME/foresight
8
+ Project-URL: Issues, https://github.com/YOUR_USERNAME/foresight/issues
9
+ Keywords: cli,monitoring,forecasting,devops,time-series,arima,machine-learning,psutil,system-monitoring
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Environment :: Console
16
+ Classifier: Topic :: System :: Monitoring
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Classifier: Intended Audience :: Developers
19
+ Classifier: Intended Audience :: System Administrators
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: psutil>=5.9.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: typer>=0.9.0
25
+ Requires-Dist: plotext>=5.2.0
26
+ Requires-Dist: pandas>=2.0.0
27
+ Requires-Dist: statsmodels>=0.14.0
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ foresight/__init__.py
4
+ foresight/cli.py
5
+ foresight/collector.py
6
+ foresight/forecaster.py
7
+ foresight/storage.py
8
+ foresight_cli.egg-info/PKG-INFO
9
+ foresight_cli.egg-info/SOURCES.txt
10
+ foresight_cli.egg-info/dependency_links.txt
11
+ foresight_cli.egg-info/entry_points.txt
12
+ foresight_cli.egg-info/requires.txt
13
+ foresight_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ foresight = foresight.cli:app
@@ -0,0 +1,6 @@
1
+ psutil>=5.9.0
2
+ rich>=13.0.0
3
+ typer>=0.9.0
4
+ plotext>=5.2.0
5
+ pandas>=2.0.0
6
+ statsmodels>=0.14.0
@@ -0,0 +1 @@
1
+ foresight
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=65", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+ [project]
5
+ name = "foresight-cli"
6
+ version = "0.1.0"
7
+ description = "Predict system resource exhaustion before it happens."
8
+ readme = "README.md"
9
+ license = { file = "LICENSE" }
10
+ requires-python = ">=3.10"
11
+ authors = [
12
+ { name = "Rishi Garg" }
13
+ ]
14
+ keywords = [
15
+ "cli", "monitoring", "forecasting", "devops",
16
+ "time-series", "arima", "machine-learning",
17
+ "psutil", "system-monitoring"
18
+ ]
19
+ classifiers = [
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Operating System :: OS Independent",
25
+ "Environment :: Console",
26
+ "Topic :: System :: Monitoring",
27
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
28
+ "Intended Audience :: Developers",
29
+ "Intended Audience :: System Administrators",
30
+ ]
31
+ dependencies = [
32
+ "psutil>=5.9.0",
33
+ "rich>=13.0.0",
34
+ "typer>=0.9.0",
35
+ "plotext>=5.2.0",
36
+ "pandas>=2.0.0",
37
+ "statsmodels>=0.14.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/YOUR_USERNAME/foresight"
42
+ Repository = "https://github.com/YOUR_USERNAME/foresight"
43
+ Issues = "https://github.com/YOUR_USERNAME/foresight/issues"
44
+
45
+ [project.scripts]
46
+ foresight = "foresight.cli:app"
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["."]
50
+ include = ["foresight*"]
51
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+