Implementována portálová část SQLite job queue.
Přidán DB helper pro jobs a job_logs bez ORM.
Přidána idempotentní migrace pro job tabulky včetně created_by_display_name.
Přidány stránky /portal/jobs a /portal/jobs/{id}.
Jobs page zobrazuje status, type, target, source, created_by, created_at, started_at, finished_at.
Job detail zobrazuje payload, result/error a job_logs.
Queued/running joby mají auto-refresh po 5 s.
Navigace portálu obsahuje odkaz na Joby.
App redeploy už nespouští deploy script přímo; vytváří queued job deploy_app s prázdným payloadem.
Po redeploy se uživatel přesměruje na detail jobu.
Přidán audit event app.redeploy.queued s metadata={"job_id": job_id}.
Přidána ochrana proti duplicitnímu aktivnímu deploy jobu pro stejnou appku.
This commit is contained in:
+213
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db.database import get_connection
|
||||
from app.db.migrations import run_migrations
|
||||
|
||||
|
||||
ACTIVE_STATUSES = ("queued", "running")
|
||||
|
||||
|
||||
def create_job(
|
||||
job_type: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
user: dict[str, Any] | None = None,
|
||||
source: str = "portal",
|
||||
):
|
||||
run_migrations()
|
||||
payload_json = json.dumps(payload or {}, ensure_ascii=False, sort_keys=True)
|
||||
created_by_user_id = user.get("id") if user else None
|
||||
created_by_username = user.get("username") if user else None
|
||||
created_by_display_name = user.get("display_name") if user else None
|
||||
|
||||
con = get_connection()
|
||||
cur = con.execute(
|
||||
"""
|
||||
INSERT INTO jobs (
|
||||
type,
|
||||
target_type,
|
||||
target_id,
|
||||
payload_json,
|
||||
status,
|
||||
created_by_user_id,
|
||||
created_by_username,
|
||||
created_by_display_name,
|
||||
source
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_type,
|
||||
target_type,
|
||||
target_id,
|
||||
payload_json,
|
||||
created_by_user_id,
|
||||
created_by_username,
|
||||
created_by_display_name,
|
||||
source,
|
||||
),
|
||||
)
|
||||
job_id = cur.lastrowid
|
||||
con.commit()
|
||||
con.close()
|
||||
append_job_log(job_id, "system", "Job queued.")
|
||||
return job_id
|
||||
|
||||
|
||||
def update_job_status(
|
||||
job_id: int,
|
||||
status: str,
|
||||
worker_id: str | None = None,
|
||||
result: dict[str, Any] | None = None,
|
||||
error_text: str | None = None,
|
||||
):
|
||||
run_migrations()
|
||||
result_json = json.dumps(result, ensure_ascii=False, sort_keys=True) if result is not None else None
|
||||
|
||||
started_sql = ", started_at = COALESCE(started_at, CURRENT_TIMESTAMP)" if status == "running" else ""
|
||||
finished_sql = ", finished_at = CURRENT_TIMESTAMP" if status in {"success", "failed", "cancelled"} else ""
|
||||
|
||||
con = get_connection()
|
||||
con.execute(
|
||||
f"""
|
||||
UPDATE jobs
|
||||
SET status = ?,
|
||||
worker_id = COALESCE(?, worker_id),
|
||||
result_json = COALESCE(?, result_json),
|
||||
error_text = COALESCE(?, error_text)
|
||||
{started_sql}
|
||||
{finished_sql}
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, worker_id, result_json, error_text, job_id),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def append_job_log(job_id: int, stream: str, message: str):
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
con.execute(
|
||||
"""
|
||||
INSERT INTO job_logs (job_id, stream, message)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(job_id, stream, message),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def get_next_queued_job():
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM jobs
|
||||
WHERE status = 'queued'
|
||||
AND NOT (
|
||||
type IN ('deploy_app', 'deploy_core_service')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jobs active
|
||||
WHERE active.id != jobs.id
|
||||
AND active.type = jobs.type
|
||||
AND active.target_type = jobs.target_type
|
||||
AND active.target_id = jobs.target_id
|
||||
AND active.status = 'running'
|
||||
)
|
||||
)
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
con.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_jobs(limit: int = 100):
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
|
||||
rows = con.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM jobs
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN status = 'running' THEN 0
|
||||
WHEN status = 'queued' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
con.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_job(job_id: int):
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
|
||||
row = con.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM jobs
|
||||
WHERE id = ?
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
|
||||
con.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_job_logs(job_id: int, limit: int = 1000):
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
|
||||
rows = con.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM job_logs
|
||||
WHERE job_id = ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
""",
|
||||
(job_id, limit),
|
||||
).fetchall()
|
||||
|
||||
con.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def has_active_deploy_job(target_type: str, target_id: str):
|
||||
run_migrations()
|
||||
con = get_connection()
|
||||
|
||||
row = con.execute(
|
||||
f"""
|
||||
SELECT id
|
||||
FROM jobs
|
||||
WHERE type IN ('deploy_app', 'deploy_core_service')
|
||||
AND target_type = ?
|
||||
AND target_id = ?
|
||||
AND status IN ({','.join('?' for _ in ACTIVE_STATUSES)})
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(target_type, target_id, *ACTIVE_STATUSES),
|
||||
).fetchone()
|
||||
|
||||
con.close()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,57 @@
|
||||
from app.db.database import get_connection
|
||||
|
||||
|
||||
def run_migrations():
|
||||
con = get_connection()
|
||||
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_by_user_id INTEGER,
|
||||
created_by_username TEXT,
|
||||
created_by_display_name TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'portal',
|
||||
worker_id TEXT,
|
||||
result_json TEXT,
|
||||
error_text TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS job_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL,
|
||||
stream TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
con.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status_created ON jobs(status, created_at)")
|
||||
con.execute("CREATE INDEX IF NOT EXISTS idx_jobs_target_status ON jobs(target_type, target_id, status)")
|
||||
con.execute("CREATE INDEX IF NOT EXISTS idx_job_logs_job_id ON job_logs(job_id, id)")
|
||||
|
||||
try:
|
||||
con.execute("ALTER TABLE jobs ADD COLUMN created_by_display_name TEXT")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
con.execute("ALTER TABLE deployments ADD COLUMN job_id INTEGER")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
con.commit()
|
||||
con.close()
|
||||
Reference in New Issue
Block a user