diff --git a/devplacepy/routers/admin/backups.py b/devplacepy/routers/admin/backups.py index 9053ee90..da6954ea 100644 --- a/devplacepy/routers/admin/backups.py +++ b/devplacepy/routers/admin/backups.py @@ -1,6 +1,7 @@ # retoor import logging +import os from pathlib import Path from typing import Annotated @@ -64,6 +65,7 @@ def _targets() -> list[dict]: def _dashboard(can_download: bool) -> dict: backups = [_backup_payload(row, can_download) for row in store.list_backups()] schedules = store.list_schedules() + threshold = int(os.environ.get("DISK_WARNING_PERCENT", "85")) return { "storage": store.compute_storage_stats(), "backups": backups, @@ -72,6 +74,8 @@ def _dashboard(can_download: bool) -> dict: "metrics": _metrics(backups), "generated_at": store.now_iso(), "can_download_backups": can_download, + "disk_warnings": store.get_disk_warnings(threshold), + "disk_warning_threshold": threshold, } @router.get("/backups", response_class=HTMLResponse) diff --git a/devplacepy/schemas/backups.py b/devplacepy/schemas/backups.py index aa876624..455bfcba 100644 --- a/devplacepy/schemas/backups.py +++ b/devplacepy/schemas/backups.py @@ -53,6 +53,13 @@ class BackupJobOut(_Out): completed_at: Optional[str] = None +class DiskWarningOut(_Out): + path: str = "" + used_pct: float = 0.0 + total_gb: float = 0.0 + used_gb: float = 0.0 + + class BackupScheduleOut(_Out): uid: str = "" name: str = "" @@ -78,3 +85,5 @@ class BackupDashboardOut(_Out): generated_at: Optional[str] = None can_download_backups: bool = False admin_section: Optional[str] = None + disk_warnings: list[DiskWarningOut] = [] + disk_warning_threshold: int = 85 diff --git a/devplacepy/services/backup/service.py b/devplacepy/services/backup/service.py index 65678c15..88892f8b 100644 --- a/devplacepy/services/backup/service.py +++ b/devplacepy/services/backup/service.py @@ -34,6 +34,10 @@ class BackupService(JobService): def __init__(self): super().__init__(name="backup", interval_seconds=15) + try: + store.seed_default_schedule() + except Exception as exc: + logger.warning("failed to seed default backup schedule: %s", exc) async def run_once(self) -> None: await super().run_once() diff --git a/devplacepy/services/backup/store.py b/devplacepy/services/backup/store.py index 85ce14c8..f0cb8bdf 100644 --- a/devplacepy/services/backup/store.py +++ b/devplacepy/services/backup/store.py @@ -375,6 +375,58 @@ def _storage_paths() -> list[tuple[str, str, Path]]: ] +def seed_default_schedule() -> None: + if "backup_schedules" not in db.tables: + ensure_tables() + schedules = get_table("backup_schedules") + existing = list(schedules.find(deleted_at=None, _limit=1)) + if existing: + return + uid = generate_uid() + s = get_table("backup_schedules") + cron_expr = "0 3 * * *" + from devplacepy.services.devii.tasks.schedule import next_run as schedule_next_run + next_run_at = schedule_next_run(cron_expr) or now_iso() + s.insert( + { + "uid": uid, + "name": "Daily database backup", + "target": "database", + "kind": "cron", + "every_seconds": 0, + "cron": cron_expr, + "enabled": 1, + "keep_last": 14, + "next_run_at": next_run_at, + "last_run_at": "", + "last_job_uid": "", + "run_count": 0, + "created_by": "system", + "created_at": now_iso(), + "updated_at": now_iso(), + "deleted_at": None, + "deleted_by": None, + } + ) + + +def get_disk_warnings(threshold: int = 85) -> list[dict]: + stats = compute_storage_stats() + disk = stats.get("disk", {}) + used_pct = disk.get("used_percent", 0.0) + warnings = [] + if used_pct >= threshold: + warnings.append( + { + "path": stats.get("data_dir", {}).get("path", str(config.DATA_DIR)), + "used_pct": used_pct, + "total_gb": round(disk.get("total_bytes", 0) / (1024**3), 1), + "used_gb": round(disk.get("used_bytes", 0) / (1024**3), 1), + } + ) + return warnings + + def compute_storage_stats() -> dict: now = time.monotonic() if ( diff --git a/devplacepy/services/containers/service.py b/devplacepy/services/containers/service.py index 529eb4b0..85204d96 100644 --- a/devplacepy/services/containers/service.py +++ b/devplacepy/services/containers/service.py @@ -128,6 +128,11 @@ class ContainerService(BaseService): await self._fire_schedules() + try: + await self._cleanup_stale_workspaces(backend) + except Exception as exc: + self.log(f"workspace cleanup failed: {exc}") + self._metric_tick += 1 if ( self._metric_tick @@ -136,6 +141,27 @@ class ContainerService(BaseService): ): await self._sample_metrics(backend, by_uid) + async def _cleanup_stale_workspaces(self, backend) -> None: + import shutil as _shutil + + workspace_dir = config.CONTAINER_WORKSPACES_DIR + if not workspace_dir.is_dir(): + return + instances = store.all_instances() + active_uids = {inst["uid"] for inst in instances} + for entry in workspace_dir.iterdir(): + if not entry.is_dir(): + continue + uid = entry.name + if uid in active_uids: + continue + try: + _shutil.rmtree(entry) + self.log(f"removed stale workspace {uid}") + except Exception as exc: + self.log(f"failed to remove workspace {uid}: {exc}") + await backend.image_prune() + async def _reconcile(self, backend, inst, ps) -> None: uid = inst["uid"] desired = inst["desired_state"] diff --git a/devplacepy/templates/admin_backups.html b/devplacepy/templates/admin_backups.html index 67e56799..28c0cba7 100644 --- a/devplacepy/templates/admin_backups.html +++ b/devplacepy/templates/admin_backups.html @@ -3,8 +3,34 @@ {{ super() }} + {% endblock %} {% block admin_content %} +{% for w in disk_warnings %} + +{% endfor %}

Backups

diff --git a/dpc.log b/dpc.log new file mode 100644 index 00000000..de366762 --- /dev/null +++ b/dpc.log @@ -0,0 +1,28 @@ +2026-07-19T09:01:55 INFO logging initialised at /workspace/repo/dpc.log +2026-07-19T09:01:55 DEBUG model=molodetz-pro fps=30 +2026-07-19T09:01:55 INFO read task from file: /workspace/prompts/research-1.txt +2026-07-19T09:01:55 INFO settings merged: model= allow=0 deny=0 ask=0 +2026-07-19T15:49:02 INFO logging initialised at /workspace/repo/dpc.log +2026-07-19T15:49:02 DEBUG model=molodetz-pro fps=30 +2026-07-19T15:49:02 INFO read task from file: /workspace/prompts/research-2.txt +2026-07-19T15:49:02 INFO settings merged: model= allow=0 deny=0 ask=0 +2026-07-19T16:41:42 INFO logging initialised at /workspace/repo/dpc.log +2026-07-19T16:41:42 DEBUG model=molodetz-pro fps=30 +2026-07-19T16:41:42 INFO read task from file: /workspace/prompts/research-3.txt +2026-07-19T16:41:42 INFO settings merged: model= allow=0 deny=0 ask=0 +2026-07-19T17:01:42 INFO logging initialised at /workspace/repo/dpc.log +2026-07-19T17:01:42 DEBUG model=molodetz-pro fps=30 +2026-07-19T17:01:42 INFO read task from file: /workspace/prompts/research-4.txt +2026-07-19T17:01:42 INFO settings merged: model= allow=0 deny=0 ask=0 +2026-07-19T17:25:26 INFO logging initialised at /workspace/repo/dpc.log +2026-07-19T17:25:26 DEBUG model=molodetz-pro fps=30 +2026-07-19T17:25:26 INFO read task from file: /workspace/prompts/execution-1.txt +2026-07-19T17:25:26 INFO settings merged: model= allow=0 deny=0 ask=0 +2026-07-19T17:44:59 INFO logging initialised at /workspace/repo/dpc.log +2026-07-19T17:44:59 DEBUG model=molodetz-pro fps=30 +2026-07-19T17:44:59 INFO read task from file: /workspace/prompts/research-5.txt +2026-07-19T17:44:59 INFO settings merged: model= allow=0 deny=0 ask=0 +2026-07-19T19:02:05 INFO logging initialised at /workspace/repo/dpc.log +2026-07-19T19:02:05 DEBUG model=molodetz-pro fps=30 +2026-07-19T19:02:05 INFO read task from file: /workspace/prompts/execution-2.txt +2026-07-19T19:02:05 INFO settings merged: model= allow=0 deny=0 ask=0