development-engine-vector 0.3.0__py3-none-any.whl
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.
- dev/__init__.py +3 -0
- dev/__main__.py +5 -0
- dev/cli/__init__.py +0 -0
- dev/cli/cli.py +674 -0
- dev/kernel/__init__.py +0 -0
- dev/kernel/config.py +46 -0
- dev/kernel/db.py +129 -0
- dev/kernel/paths.py +85 -0
- dev/kernel/pmf_kernel.py +432 -0
- dev/kernel/selfcheck.py +137 -0
- dev/ui/__init__.py +0 -0
- dev/ui/actions.py +47 -0
- dev/ui/db/__init__.py +26 -0
- dev/ui/db/base.py +75 -0
- dev/ui/db/checks.py +16 -0
- dev/ui/db/events.py +18 -0
- dev/ui/db/health.py +34 -0
- dev/ui/db/identity.py +12 -0
- dev/ui/db/manifest.py +35 -0
- dev/ui/db/overview.py +47 -0
- dev/ui/db/runs.py +47 -0
- dev/ui/routes.py +114 -0
- dev/ui/static/__init__.py +0 -0
- dev/ui/static/web.css +722 -0
- dev/ui/static/web.js +528 -0
- dev/ui/templates.py +231 -0
- dev/ui/web.py +28 -0
- dev/utils.py +93 -0
- dev/vcs/__init__.py +0 -0
- dev/vcs/git.py +107 -0
- dev/vcs/github.py +77 -0
- dev/workflow/__init__.py +0 -0
- dev/workflow/cda.py +143 -0
- dev/workflow/changelog.py +102 -0
- dev/workflow/preflight.py +307 -0
- dev/workflow/release.py +217 -0
- dev/workflow/versioning.py +87 -0
- development_engine_vector-0.3.0.dist-info/METADATA +252 -0
- development_engine_vector-0.3.0.dist-info/RECORD +41 -0
- development_engine_vector-0.3.0.dist-info/WHEEL +4 -0
- development_engine_vector-0.3.0.dist-info/entry_points.txt +2 -0
dev/kernel/selfcheck.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""dev.kernel.selfcheck — the engine knows itself.
|
|
2
|
+
|
|
3
|
+
Checks:
|
|
4
|
+
version — version file exists and is valid semver, matches __version__
|
|
5
|
+
install_path — editable install of dev resolves to this source directory
|
|
6
|
+
db — dev.db accessible with WAL mode (soft — OK if not yet created)
|
|
7
|
+
tools — required tools (git, python3) are on PATH
|
|
8
|
+
dependencies — all required imports load without error
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import sqlite3
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from dev.kernel.paths import DB_PATH
|
|
21
|
+
|
|
22
|
+
PACKAGE_DIR = Path(__file__).resolve().parent.parent # dev/
|
|
23
|
+
SOURCE_DIR = PACKAGE_DIR.parent # source/
|
|
24
|
+
VERSION_FILE = SOURCE_DIR / "version"
|
|
25
|
+
|
|
26
|
+
REQUIRED_TOOLS = ["git", "python3"]
|
|
27
|
+
OPTIONAL_TOOLS = ["twine"]
|
|
28
|
+
REQUIRED_IMPORTS = ["click", "sqlite3"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── result builders ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
def _ok(name: str, message: str, details: Optional[str] = None) -> dict:
|
|
34
|
+
r = {"name": name, "passed": True, "message": message}
|
|
35
|
+
if details:
|
|
36
|
+
r["details"] = details
|
|
37
|
+
return r
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _fail(name: str, message: str, details: Optional[str] = None) -> dict:
|
|
41
|
+
r = {"name": name, "passed": False, "message": message}
|
|
42
|
+
if details:
|
|
43
|
+
r["details"] = details
|
|
44
|
+
return r
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── individual checks ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def check_version() -> dict:
|
|
50
|
+
if not VERSION_FILE.exists():
|
|
51
|
+
return _fail("version", "version file not found")
|
|
52
|
+
version = VERSION_FILE.read_text().strip()
|
|
53
|
+
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
|
|
54
|
+
return _fail("version", f"version is not valid semver: {version!r}")
|
|
55
|
+
try:
|
|
56
|
+
from dev import __version__
|
|
57
|
+
if __version__ != version:
|
|
58
|
+
return _fail(
|
|
59
|
+
"version",
|
|
60
|
+
f"version file ({version}) does not match __version__ ({__version__})")
|
|
61
|
+
except (ImportError, AttributeError):
|
|
62
|
+
pass
|
|
63
|
+
return _ok("version", f"version is valid semver: {version}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_install_path() -> dict:
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
[sys.executable, "-c",
|
|
70
|
+
"import dev, pathlib; "
|
|
71
|
+
"print(pathlib.Path(dev.__file__).parent.parent.resolve())"],
|
|
72
|
+
capture_output=True, text=True,
|
|
73
|
+
)
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
return _fail("install_path", "dev not importable — editable install broken")
|
|
76
|
+
install_dir = Path(result.stdout.strip()).resolve()
|
|
77
|
+
if install_dir == SOURCE_DIR:
|
|
78
|
+
return _ok("install_path", f"editable install → {install_dir}")
|
|
79
|
+
return _fail(
|
|
80
|
+
"install_path",
|
|
81
|
+
f"install points to wrong path: {install_dir} (expected {SOURCE_DIR})")
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
return _fail("install_path", f"install_path check error: {exc}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_db() -> dict:
|
|
87
|
+
if not DB_PATH.exists():
|
|
88
|
+
return _ok("db", "dev.db not yet created (will be on first run)")
|
|
89
|
+
try:
|
|
90
|
+
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=5)
|
|
91
|
+
row = conn.execute("PRAGMA journal_mode").fetchone()
|
|
92
|
+
conn.close()
|
|
93
|
+
mode = row[0] if row else "unknown"
|
|
94
|
+
if mode != "wal":
|
|
95
|
+
return _fail("db", f"DB accessible but journal_mode={mode} (expected wal)")
|
|
96
|
+
return _ok("db", "dev.db present and accessible (WAL mode)")
|
|
97
|
+
except sqlite3.DatabaseError as exc:
|
|
98
|
+
return _fail("db", f"DB is corrupt or unreadable: {exc}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_tools() -> dict:
|
|
102
|
+
missing = [t for t in REQUIRED_TOOLS if not shutil.which(t)]
|
|
103
|
+
if missing:
|
|
104
|
+
return _fail("tools", f"Required tools missing: {', '.join(missing)}")
|
|
105
|
+
optional_missing = [t for t in OPTIONAL_TOOLS if not shutil.which(t)]
|
|
106
|
+
opt_details: Optional[str] = (
|
|
107
|
+
f"Optional tools not found: {', '.join(optional_missing)}"
|
|
108
|
+
if optional_missing else None
|
|
109
|
+
)
|
|
110
|
+
return _ok("tools", "All required tools present", opt_details)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def check_dependencies() -> dict:
|
|
114
|
+
failed = []
|
|
115
|
+
for mod in REQUIRED_IMPORTS:
|
|
116
|
+
try:
|
|
117
|
+
importlib.import_module(mod)
|
|
118
|
+
except ImportError:
|
|
119
|
+
failed.append(mod)
|
|
120
|
+
if failed:
|
|
121
|
+
return _fail("dependencies", f"Missing imports: {', '.join(failed)}")
|
|
122
|
+
return _ok("dependencies", f"All {len(REQUIRED_IMPORTS)} required imports load")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── runner ────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
def run_all() -> dict:
|
|
128
|
+
"""Run all selfchecks. Returns summary dict with checks list."""
|
|
129
|
+
checks = [
|
|
130
|
+
check_version(),
|
|
131
|
+
check_install_path(),
|
|
132
|
+
check_db(),
|
|
133
|
+
check_tools(),
|
|
134
|
+
check_dependencies(),
|
|
135
|
+
]
|
|
136
|
+
passed = sum(1 for c in checks if c["passed"])
|
|
137
|
+
return {"checks": checks, "passed": passed, "failed": len(checks) - passed}
|
dev/ui/__init__.py
ADDED
|
File without changes
|
dev/ui/actions.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
ACTION_STATE: Dict[str, Any] = {}
|
|
9
|
+
ACTION_LOCK = threading.Lock()
|
|
10
|
+
|
|
11
|
+
# vet.py lives at control/scripts/vet.py relative to source/
|
|
12
|
+
_VET_SCRIPT = Path(__file__).resolve().parents[3] / "control" / "scripts" / "vet.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_action_background(action_id, action_name):
|
|
16
|
+
"""Execute a control action in a background thread."""
|
|
17
|
+
with ACTION_LOCK:
|
|
18
|
+
ACTION_STATE[action_id] = {
|
|
19
|
+
"status": "running",
|
|
20
|
+
"action": action_name,
|
|
21
|
+
"started_at": datetime.now().isoformat(),
|
|
22
|
+
"output": "",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
if action_name == "vet":
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
[sys.executable, str(_VET_SCRIPT)],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
timeout=300,
|
|
32
|
+
)
|
|
33
|
+
else:
|
|
34
|
+
result = None
|
|
35
|
+
|
|
36
|
+
with ACTION_LOCK:
|
|
37
|
+
if result:
|
|
38
|
+
ACTION_STATE[action_id]["status"] = "completed" if result.returncode == 0 else "failed"
|
|
39
|
+
ACTION_STATE[action_id]["output"] = result.stdout + result.stderr
|
|
40
|
+
ACTION_STATE[action_id]["returncode"] = result.returncode
|
|
41
|
+
ACTION_STATE[action_id]["completed_at"] = datetime.now().isoformat()
|
|
42
|
+
|
|
43
|
+
except Exception as e:
|
|
44
|
+
with ACTION_LOCK:
|
|
45
|
+
ACTION_STATE[action_id]["status"] = "error"
|
|
46
|
+
ACTION_STATE[action_id]["output"] = str(e)
|
|
47
|
+
ACTION_STATE[action_id]["completed_at"] = datetime.now().isoformat()
|
dev/ui/db/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .base import get_db, query_rows, query_one, safe_rows, safe_one, table_exists, execute_stmt
|
|
2
|
+
from .overview import get_overview
|
|
3
|
+
from .runs import get_runs, get_run_detail
|
|
4
|
+
from .health import get_health
|
|
5
|
+
from .checks import get_checks
|
|
6
|
+
from .manifest import get_manifest
|
|
7
|
+
from .identity import get_identity
|
|
8
|
+
from .events import get_events
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"get_db",
|
|
12
|
+
"query_rows",
|
|
13
|
+
"query_one",
|
|
14
|
+
"safe_rows",
|
|
15
|
+
"safe_one",
|
|
16
|
+
"table_exists",
|
|
17
|
+
"execute_stmt",
|
|
18
|
+
"get_overview",
|
|
19
|
+
"get_runs",
|
|
20
|
+
"get_run_detail",
|
|
21
|
+
"get_health",
|
|
22
|
+
"get_checks",
|
|
23
|
+
"get_manifest",
|
|
24
|
+
"get_identity",
|
|
25
|
+
"get_events",
|
|
26
|
+
]
|
dev/ui/db/base.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sqlite3
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Control DB: adjacent to source/ in the intel system layout.
|
|
6
|
+
# Overridable via DEV_CONTROL_DB env var for non-standard installs.
|
|
7
|
+
_DEFAULT = Path(__file__).resolve().parents[4] / "control" / "data" / "control.db"
|
|
8
|
+
CONTROL_DB_PATH = Path(os.environ.get("DEV_CONTROL_DB", str(_DEFAULT)))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_db():
|
|
12
|
+
"""Get a WAL connection to the control database."""
|
|
13
|
+
conn = sqlite3.connect(str(CONTROL_DB_PATH), timeout=10)
|
|
14
|
+
conn.row_factory = sqlite3.Row
|
|
15
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
16
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
17
|
+
return conn
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def query_rows(sql, params=()):
|
|
21
|
+
"""Execute SELECT and return rows as dicts."""
|
|
22
|
+
try:
|
|
23
|
+
conn = get_db()
|
|
24
|
+
cursor = conn.execute(sql, params)
|
|
25
|
+
rows = [dict(row) for row in cursor.fetchall()]
|
|
26
|
+
conn.close()
|
|
27
|
+
return rows
|
|
28
|
+
except Exception as e:
|
|
29
|
+
return {"error": str(e)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def query_one(sql, params=()):
|
|
33
|
+
"""Execute SELECT and return single row or None."""
|
|
34
|
+
rows = query_rows(sql, params)
|
|
35
|
+
if isinstance(rows, dict) and "error" in rows:
|
|
36
|
+
return rows
|
|
37
|
+
return rows[0] if rows else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def safe_rows(rows):
|
|
41
|
+
"""Normalize query_rows output to an array for APIs."""
|
|
42
|
+
if isinstance(rows, dict) and "error" in rows:
|
|
43
|
+
return []
|
|
44
|
+
return rows or []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def safe_one(row):
|
|
48
|
+
"""Normalize query_one output to a dict or None."""
|
|
49
|
+
if isinstance(row, dict) and "error" in row:
|
|
50
|
+
return None
|
|
51
|
+
return row
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def table_exists(table_name):
|
|
55
|
+
"""Return True if a table exists in the control database."""
|
|
56
|
+
try:
|
|
57
|
+
row = query_one(
|
|
58
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
59
|
+
(table_name,)
|
|
60
|
+
)
|
|
61
|
+
return bool(row)
|
|
62
|
+
except Exception:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def execute_stmt(sql, params=()):
|
|
67
|
+
"""Execute INSERT/UPDATE/DELETE statement."""
|
|
68
|
+
try:
|
|
69
|
+
conn = get_db()
|
|
70
|
+
conn.execute(sql, params)
|
|
71
|
+
conn.commit()
|
|
72
|
+
conn.close()
|
|
73
|
+
return {"ok": True}
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return {"error": str(e)}
|
dev/ui/db/checks.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .base import query_rows, safe_rows
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_checks():
|
|
5
|
+
"""Return all check definitions."""
|
|
6
|
+
try:
|
|
7
|
+
rows = safe_rows(query_rows(
|
|
8
|
+
"""
|
|
9
|
+
SELECT id, name, kind, command, workdir, enabled, order_idx, timeout, added_at
|
|
10
|
+
FROM checks
|
|
11
|
+
ORDER BY order_idx, id
|
|
12
|
+
"""
|
|
13
|
+
))
|
|
14
|
+
return {"checks": rows, "total": len(rows)}
|
|
15
|
+
except Exception as e:
|
|
16
|
+
return {"error": str(e)}
|
dev/ui/db/events.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .base import query_rows, safe_rows
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_events(limit=100):
|
|
5
|
+
"""Return recent events."""
|
|
6
|
+
try:
|
|
7
|
+
rows = safe_rows(query_rows(
|
|
8
|
+
"""
|
|
9
|
+
SELECT id, occurred_at, kind, actor, subject, detail
|
|
10
|
+
FROM events
|
|
11
|
+
ORDER BY id DESC
|
|
12
|
+
LIMIT ?
|
|
13
|
+
""",
|
|
14
|
+
(limit,),
|
|
15
|
+
))
|
|
16
|
+
return {"events": rows, "total": len(rows)}
|
|
17
|
+
except Exception as e:
|
|
18
|
+
return {"error": str(e)}
|
dev/ui/db/health.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .base import query_rows, safe_rows
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_health(check_name=None, limit=200):
|
|
5
|
+
"""Return health check results, optionally filtered by check name."""
|
|
6
|
+
try:
|
|
7
|
+
if check_name:
|
|
8
|
+
rows = safe_rows(query_rows(
|
|
9
|
+
"""
|
|
10
|
+
SELECT h.id, h.run_at, h.check_name, h.passed, h.message,
|
|
11
|
+
r.id as run_id, r.exit_code as run_exit_code
|
|
12
|
+
FROM health h
|
|
13
|
+
LEFT JOIN runs r ON r.started_at = h.run_at
|
|
14
|
+
WHERE h.check_name = ?
|
|
15
|
+
ORDER BY h.id DESC
|
|
16
|
+
LIMIT ?
|
|
17
|
+
""",
|
|
18
|
+
(check_name, limit),
|
|
19
|
+
))
|
|
20
|
+
else:
|
|
21
|
+
rows = safe_rows(query_rows(
|
|
22
|
+
"""
|
|
23
|
+
SELECT h.id, h.run_at, h.check_name, h.passed, h.message,
|
|
24
|
+
r.id as run_id, r.exit_code as run_exit_code
|
|
25
|
+
FROM health h
|
|
26
|
+
LEFT JOIN runs r ON r.started_at = h.run_at
|
|
27
|
+
ORDER BY h.id DESC
|
|
28
|
+
LIMIT ?
|
|
29
|
+
""",
|
|
30
|
+
(limit,),
|
|
31
|
+
))
|
|
32
|
+
return {"health": rows, "total": len(rows)}
|
|
33
|
+
except Exception as e:
|
|
34
|
+
return {"error": str(e)}
|
dev/ui/db/identity.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .base import query_rows, safe_rows
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_identity():
|
|
5
|
+
"""Return all identity key-value records."""
|
|
6
|
+
try:
|
|
7
|
+
rows = safe_rows(query_rows(
|
|
8
|
+
"SELECT id, key, value, updated_at FROM identity ORDER BY key"
|
|
9
|
+
))
|
|
10
|
+
return {"identity": rows, "total": len(rows)}
|
|
11
|
+
except Exception as e:
|
|
12
|
+
return {"error": str(e)}
|
dev/ui/db/manifest.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .base import query_rows, safe_rows
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_manifest(q=None, limit=200, offset=0):
|
|
5
|
+
"""Return manifest entries, optionally filtered by filename/path."""
|
|
6
|
+
try:
|
|
7
|
+
total_rows = query_rows("SELECT COUNT(*) as total FROM manifest")
|
|
8
|
+
total = total_rows[0]["total"] if total_rows and not isinstance(total_rows, dict) else 0
|
|
9
|
+
|
|
10
|
+
if q:
|
|
11
|
+
rows = safe_rows(query_rows(
|
|
12
|
+
"""
|
|
13
|
+
SELECT id, relative_path, filename, extension, size_bytes,
|
|
14
|
+
file_type, is_tracked, is_gitignored, is_binary, mtime
|
|
15
|
+
FROM manifest
|
|
16
|
+
WHERE relative_path LIKE ? OR filename LIKE ?
|
|
17
|
+
ORDER BY relative_path
|
|
18
|
+
LIMIT ? OFFSET ?
|
|
19
|
+
""",
|
|
20
|
+
(f"%{q}%", f"%{q}%", limit, offset),
|
|
21
|
+
))
|
|
22
|
+
else:
|
|
23
|
+
rows = safe_rows(query_rows(
|
|
24
|
+
"""
|
|
25
|
+
SELECT id, relative_path, filename, extension, size_bytes,
|
|
26
|
+
file_type, is_tracked, is_gitignored, is_binary, mtime
|
|
27
|
+
FROM manifest
|
|
28
|
+
ORDER BY relative_path
|
|
29
|
+
LIMIT ? OFFSET ?
|
|
30
|
+
""",
|
|
31
|
+
(limit, offset),
|
|
32
|
+
))
|
|
33
|
+
return {"files": rows, "total": total}
|
|
34
|
+
except Exception as e:
|
|
35
|
+
return {"error": str(e)}
|
dev/ui/db/overview.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from .base import query_one, query_rows, safe_one, safe_rows, table_exists
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_overview():
|
|
5
|
+
"""Dashboard overview stats."""
|
|
6
|
+
try:
|
|
7
|
+
has_runs = table_exists("runs")
|
|
8
|
+
has_health = table_exists("health")
|
|
9
|
+
has_checks = table_exists("checks")
|
|
10
|
+
has_manifest = table_exists("manifest")
|
|
11
|
+
|
|
12
|
+
stats = query_one(f"""
|
|
13
|
+
SELECT
|
|
14
|
+
{("(SELECT COUNT(*) FROM runs)" if has_runs else "0")} as total_runs,
|
|
15
|
+
{("(SELECT COUNT(*) FROM checks WHERE enabled=1)" if has_checks else "0")} as enabled_checks,
|
|
16
|
+
{("(SELECT COUNT(*) FROM manifest)" if has_manifest else "0")} as manifest_files,
|
|
17
|
+
{("(SELECT exit_code FROM runs ORDER BY id DESC LIMIT 1)" if has_runs else "NULL")} as last_exit_code,
|
|
18
|
+
{("(SELECT finished_at FROM runs ORDER BY id DESC LIMIT 1)" if has_runs else "NULL")} as last_run_at,
|
|
19
|
+
{("(SELECT notes FROM runs ORDER BY id DESC LIMIT 1)" if has_runs else "NULL")} as last_notes,
|
|
20
|
+
{("(SELECT COUNT(*) FROM runs WHERE exit_code=0)" if has_runs else "0")} as clean_runs,
|
|
21
|
+
{("(SELECT COUNT(*) FROM runs WHERE exit_code!=0 AND exit_code IS NOT NULL)" if has_runs else "0")} as failed_runs
|
|
22
|
+
""")
|
|
23
|
+
|
|
24
|
+
recent_runs = safe_rows(query_rows("""
|
|
25
|
+
SELECT id, started_at, finished_at, trigger, exit_code, notes
|
|
26
|
+
FROM runs
|
|
27
|
+
ORDER BY id DESC
|
|
28
|
+
LIMIT 10
|
|
29
|
+
""")) if has_runs else []
|
|
30
|
+
|
|
31
|
+
check_pass_rates = safe_rows(query_rows("""
|
|
32
|
+
SELECT h.check_name,
|
|
33
|
+
COUNT(*) as total,
|
|
34
|
+
SUM(h.passed) as passed,
|
|
35
|
+
ROUND(100.0 * SUM(h.passed) / COUNT(*), 1) as pass_rate
|
|
36
|
+
FROM health h
|
|
37
|
+
GROUP BY h.check_name
|
|
38
|
+
ORDER BY h.check_name
|
|
39
|
+
""")) if has_health else []
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"stats": dict(safe_one(stats)) if safe_one(stats) else {},
|
|
43
|
+
"recent_runs": recent_runs,
|
|
44
|
+
"check_pass_rates": check_pass_rates,
|
|
45
|
+
}
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return {"error": str(e)}
|
dev/ui/db/runs.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from .base import query_rows, safe_rows
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_runs(limit=50, offset=0):
|
|
5
|
+
"""Return paginated vet run history."""
|
|
6
|
+
try:
|
|
7
|
+
total_row = query_rows("SELECT COUNT(*) as total FROM runs")
|
|
8
|
+
total = total_row[0]["total"] if total_row and not isinstance(total_row, dict) else 0
|
|
9
|
+
|
|
10
|
+
rows = safe_rows(query_rows(
|
|
11
|
+
"""
|
|
12
|
+
SELECT id, started_at, finished_at, trigger, exit_code, notes
|
|
13
|
+
FROM runs
|
|
14
|
+
ORDER BY id DESC
|
|
15
|
+
LIMIT ? OFFSET ?
|
|
16
|
+
""",
|
|
17
|
+
(limit, offset),
|
|
18
|
+
))
|
|
19
|
+
return {"runs": rows, "total": total}
|
|
20
|
+
except Exception as e:
|
|
21
|
+
return {"error": str(e)}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_run_detail(run_id):
|
|
25
|
+
"""Return a single run with its per-check health rows."""
|
|
26
|
+
try:
|
|
27
|
+
run_rows = query_rows(
|
|
28
|
+
"SELECT id, started_at, finished_at, trigger, exit_code, notes FROM runs WHERE id=?",
|
|
29
|
+
(run_id,),
|
|
30
|
+
)
|
|
31
|
+
if not run_rows or isinstance(run_rows, dict):
|
|
32
|
+
return {"error": "run not found"}
|
|
33
|
+
run = run_rows[0]
|
|
34
|
+
|
|
35
|
+
health = safe_rows(query_rows(
|
|
36
|
+
"""
|
|
37
|
+
SELECT h.check_name, h.passed, h.message
|
|
38
|
+
FROM health h
|
|
39
|
+
WHERE h.run_at = ?
|
|
40
|
+
ORDER BY h.check_name
|
|
41
|
+
""",
|
|
42
|
+
(run["started_at"],),
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
return {"run": run, "health": health}
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return {"error": str(e)}
|
dev/ui/routes.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
import traceback
|
|
5
|
+
from urllib.parse import parse_qs
|
|
6
|
+
|
|
7
|
+
from dev.ui.actions import run_action_background
|
|
8
|
+
from dev.ui.db import (
|
|
9
|
+
get_overview, get_runs, get_run_detail, get_health,
|
|
10
|
+
get_checks, get_manifest, get_identity, get_events,
|
|
11
|
+
query_rows,
|
|
12
|
+
)
|
|
13
|
+
from dev.ui.templates import INDEX_HTML, PAGE_REGISTRY_JS
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_static_dir = Path(__file__).parent / "static"
|
|
18
|
+
_CSS = (_static_dir / "web.css").read_text(encoding="utf-8")
|
|
19
|
+
_STATIC_JS = (_static_dir / "web.js").read_text(encoding="utf-8")
|
|
20
|
+
_APP_JS = PAGE_REGISTRY_JS + _STATIC_JS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def application(environ, start_response):
|
|
24
|
+
"""WSGI application."""
|
|
25
|
+
method = environ["REQUEST_METHOD"]
|
|
26
|
+
path = environ["PATH_INFO"]
|
|
27
|
+
query = parse_qs(environ.get("QUERY_STRING", ""))
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
if path == "/":
|
|
31
|
+
response = (
|
|
32
|
+
INDEX_HTML
|
|
33
|
+
.replace("{{STYLE_CSS}}", _CSS)
|
|
34
|
+
.replace("{{APP_JS}}", _APP_JS)
|
|
35
|
+
.encode("utf-8")
|
|
36
|
+
)
|
|
37
|
+
start_response("200 OK", [("Content-Type", "text/html; charset=utf-8")])
|
|
38
|
+
return [response]
|
|
39
|
+
|
|
40
|
+
elif path == "/api/overview":
|
|
41
|
+
data = get_overview()
|
|
42
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
43
|
+
return [json.dumps(data).encode("utf-8")]
|
|
44
|
+
|
|
45
|
+
elif path == "/api/runs":
|
|
46
|
+
limit = int(query.get("limit", ["50"])[0])
|
|
47
|
+
offset = int(query.get("offset", ["0"])[0])
|
|
48
|
+
data = get_runs(limit, offset)
|
|
49
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
50
|
+
return [json.dumps(data).encode("utf-8")]
|
|
51
|
+
|
|
52
|
+
elif path == "/api/run":
|
|
53
|
+
run_id = int(query.get("run_id", ["0"])[0])
|
|
54
|
+
data = get_run_detail(run_id)
|
|
55
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
56
|
+
return [json.dumps(data).encode("utf-8")]
|
|
57
|
+
|
|
58
|
+
elif path == "/api/health":
|
|
59
|
+
check_name = query.get("check", [None])[0]
|
|
60
|
+
data = get_health(check_name)
|
|
61
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
62
|
+
return [json.dumps(data).encode("utf-8")]
|
|
63
|
+
|
|
64
|
+
elif path == "/api/checks":
|
|
65
|
+
data = get_checks()
|
|
66
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
67
|
+
return [json.dumps(data).encode("utf-8")]
|
|
68
|
+
|
|
69
|
+
elif path == "/api/manifest":
|
|
70
|
+
q = query.get("q", [""])[0]
|
|
71
|
+
data = get_manifest(q if q else None)
|
|
72
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
73
|
+
return [json.dumps(data).encode("utf-8")]
|
|
74
|
+
|
|
75
|
+
elif path == "/api/identity":
|
|
76
|
+
data = get_identity()
|
|
77
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
78
|
+
return [json.dumps(data).encode("utf-8")]
|
|
79
|
+
|
|
80
|
+
elif path == "/api/events":
|
|
81
|
+
data = get_events()
|
|
82
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
83
|
+
return [json.dumps(data).encode("utf-8")]
|
|
84
|
+
|
|
85
|
+
elif path == "/api/action" and method == "POST":
|
|
86
|
+
body = environ["wsgi.input"].read()
|
|
87
|
+
payload = json.loads(body.decode("utf-8"))
|
|
88
|
+
action = payload.get("action", "")
|
|
89
|
+
|
|
90
|
+
action_id = f"{action}_{int(time.time() * 1000)}"
|
|
91
|
+
thread = threading.Thread(target=run_action_background, args=(action_id, action))
|
|
92
|
+
thread.daemon = True
|
|
93
|
+
thread.start()
|
|
94
|
+
|
|
95
|
+
data = {"ok": True, "action": action, "action_id": action_id, "message": f"Started {action}..."}
|
|
96
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
97
|
+
return [json.dumps(data).encode("utf-8")]
|
|
98
|
+
|
|
99
|
+
elif path == "/api/query" and method == "POST":
|
|
100
|
+
body = environ["wsgi.input"].read()
|
|
101
|
+
payload = json.loads(body.decode("utf-8"))
|
|
102
|
+
sql = payload.get("sql", "")
|
|
103
|
+
rows = query_rows(sql)
|
|
104
|
+
start_response("200 OK", [("Content-Type", "application/json")])
|
|
105
|
+
return [json.dumps({"rows": rows}).encode("utf-8")]
|
|
106
|
+
|
|
107
|
+
else:
|
|
108
|
+
start_response("404 Not Found", [("Content-Type", "text/plain")])
|
|
109
|
+
return [b"Not Found"]
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
traceback.print_exc()
|
|
113
|
+
start_response("500 Internal Server Error", [("Content-Type", "application/json")])
|
|
114
|
+
return [json.dumps({"error": str(e)}).encode("utf-8")]
|
|
File without changes
|