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()
|
||||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from .config import read_env_value
|
from .config import read_env_value
|
||||||
from .routes import apps, audit, auth, backups, deployments, health
|
from .routes import apps, audit, auth, backups, deployments, health, jobs
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -27,6 +27,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(apps.router)
|
app.include_router(apps.router)
|
||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
app.include_router(deployments.router)
|
app.include_router(deployments.router)
|
||||||
|
app.include_router(jobs.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
+16
-16
@@ -16,8 +16,9 @@ from ..config import (
|
|||||||
)
|
)
|
||||||
from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources
|
from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources
|
||||||
from ..db.audit import log_audit_event
|
from ..db.audit import log_audit_event
|
||||||
|
from ..db.jobs import create_job, has_active_deploy_job
|
||||||
from ..routes.deployments import render_status_pill
|
from ..routes.deployments import render_status_pill
|
||||||
from ..shell import run_command, run_command_background
|
from ..shell import run_command
|
||||||
from ..templates.layout import page, render_result
|
from ..templates.layout import page, render_result
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -250,28 +251,27 @@ def redeploy_app(app_id: str, user=Depends(require_user)):
|
|||||||
if not app:
|
if not app:
|
||||||
raise HTTPException(status_code=404, detail="App not found")
|
raise HTTPException(status_code=404, detail="App not found")
|
||||||
|
|
||||||
process = run_command_background(
|
active_job = has_active_deploy_job("app", app_id)
|
||||||
[DEPLOY_SCRIPT, app_id],
|
if active_job:
|
||||||
extra_env={
|
return RedirectResponse(url=f"/portal/jobs/{active_job['id']}", status_code=303)
|
||||||
"APPFACTORY_TRIGGER_SOURCE": "portal",
|
|
||||||
"APPFACTORY_TRIGGERED_BY_USER_ID": user.get("id"),
|
job_id = create_job(
|
||||||
"APPFACTORY_TRIGGERED_BY_USERNAME": user.get("username"),
|
job_type="deploy_app",
|
||||||
"APPFACTORY_TRIGGERED_BY_DISPLAY_NAME": user.get("display_name") or user.get("username"),
|
target_type="app",
|
||||||
},
|
target_id=app_id,
|
||||||
|
payload={},
|
||||||
|
user=user,
|
||||||
|
source="portal",
|
||||||
)
|
)
|
||||||
log_audit_event(
|
log_audit_event(
|
||||||
user,
|
user,
|
||||||
action="app.redeploy",
|
action="app.redeploy.queued",
|
||||||
target_type="app",
|
target_type="app",
|
||||||
target_id=app_id,
|
target_id=app_id,
|
||||||
metadata={
|
metadata={"job_id": job_id},
|
||||||
"app_id": app_id,
|
|
||||||
"pid": process.pid,
|
|
||||||
"trigger_source": "portal",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(url=f"/portal/apps/{quote(app_id, safe='')}", status_code=303)
|
return RedirectResponse(url=f"/portal/jobs/{job_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/new-app", response_class=HTMLResponse)
|
@router.get("/new-app", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import html
|
||||||
|
import json
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from app.auth import require_user
|
||||||
|
from app.db.jobs import get_job, get_job_logs, get_jobs
|
||||||
|
from app.routes.deployments import calculate_duration, render_status_pill
|
||||||
|
from app.templates.layout import page
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
LIVE_STATUSES = {"queued", "running"}
|
||||||
|
|
||||||
|
|
||||||
|
def render_job_status(status: str | None) -> str:
|
||||||
|
return render_status_pill(status)
|
||||||
|
|
||||||
|
|
||||||
|
def pretty_json(value: str | None) -> str:
|
||||||
|
if not value:
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.dumps(json.loads(value), ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs", response_class=HTMLResponse)
|
||||||
|
def jobs_page(request: Request, user=Depends(require_user)):
|
||||||
|
jobs = get_jobs()
|
||||||
|
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if any(
|
||||||
|
(job.get("status") or "").lower() in LIVE_STATUSES for job in jobs
|
||||||
|
) else ""
|
||||||
|
|
||||||
|
rows = ""
|
||||||
|
for job in jobs:
|
||||||
|
job_id = html.escape(str(job.get("id", "")))
|
||||||
|
status = render_job_status(job.get("status"))
|
||||||
|
source = html.escape(job.get("source", "") or "")
|
||||||
|
target_type = html.escape(job.get("target_type", "") or "")
|
||||||
|
target_id = html.escape(job.get("target_id", "") or "")
|
||||||
|
job_type = html.escape(job.get("type", "") or "")
|
||||||
|
created_by = html.escape(job.get("created_by_display_name") or job.get("created_by_username") or "")
|
||||||
|
created_at = html.escape(job.get("created_at", "") or "")
|
||||||
|
started_at = html.escape(job.get("started_at", "") or "")
|
||||||
|
finished_at = html.escape(job.get("finished_at", "") or "")
|
||||||
|
row_class = ' class="running-row"' if (job.get("status") or "").lower() == "running" else ""
|
||||||
|
|
||||||
|
rows += f"""
|
||||||
|
<tr{row_class}>
|
||||||
|
<td>
|
||||||
|
<strong><a href="/portal/jobs/{job_id}">#{job_id}</a></strong><br>
|
||||||
|
<span class="muted">{job_type}</span>
|
||||||
|
</td>
|
||||||
|
<td>{status}</td>
|
||||||
|
<td>{source}</td>
|
||||||
|
<td>{target_type}: <strong>{target_id}</strong></td>
|
||||||
|
<td>{created_by}</td>
|
||||||
|
<td>{created_at}</td>
|
||||||
|
<td>{started_at}</td>
|
||||||
|
<td>{finished_at}</td>
|
||||||
|
<td class="actions-cell"><a class="btn" href="/portal/jobs/{job_id}">Detail</a></td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = '<tr><td colspan="9">Zatím nejsou evidované žádné joby.</td></tr>'
|
||||||
|
|
||||||
|
return page(
|
||||||
|
"Joby",
|
||||||
|
f"""
|
||||||
|
{refresh}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Joby</h2>
|
||||||
|
<p class="muted">Fronta portálových a webhook úloh připravená pro centrální worker.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Fronta</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Created by</th>
|
||||||
|
<th>Created at</th>
|
||||||
|
<th>Started at</th>
|
||||||
|
<th>Finished at</th>
|
||||||
|
<th>Akce</th>
|
||||||
|
</tr>
|
||||||
|
{rows}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}", response_class=HTMLResponse)
|
||||||
|
def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
|
||||||
|
job = get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
status_value = (job.get("status") or "").lower()
|
||||||
|
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if status_value in LIVE_STATUSES else ""
|
||||||
|
title = f"Job #{html.escape(str(job.get('id', job_id)))}"
|
||||||
|
status = render_job_status(job.get("status"))
|
||||||
|
target_type = job.get("target_type", "") or ""
|
||||||
|
target_id = job.get("target_id", "") or ""
|
||||||
|
target_id_html = html.escape(target_id)
|
||||||
|
target_url = f"/portal/apps/{quote(target_id, safe='')}" if target_type == "app" else "/portal/jobs"
|
||||||
|
duration = html.escape(calculate_duration(job.get("started_at"), job.get("finished_at")))
|
||||||
|
payload = html.escape(pretty_json(job.get("payload_json")))
|
||||||
|
result = html.escape(pretty_json(job.get("result_json")))
|
||||||
|
error_text = html.escape(job.get("error_text", "") or "")
|
||||||
|
|
||||||
|
log_blocks = ""
|
||||||
|
for log in get_job_logs(job_id):
|
||||||
|
stream = html.escape(log.get("stream", "") or "system")
|
||||||
|
created_at = html.escape(log.get("created_at", "") or "")
|
||||||
|
message = html.escape(log.get("message", "") or "")
|
||||||
|
class_name = "log-stderr" if stream == "stderr" else "log-stdout"
|
||||||
|
log_blocks += f"""
|
||||||
|
<div class="job-log-entry">
|
||||||
|
<div class="muted">{created_at} · {stream}</div>
|
||||||
|
<pre class="log-viewer {class_name}">{message}</pre>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not log_blocks:
|
||||||
|
log_blocks = '<p class="muted">Zatím nejsou uložené žádné logy.</p>'
|
||||||
|
|
||||||
|
return page(
|
||||||
|
title,
|
||||||
|
f"""
|
||||||
|
{refresh}
|
||||||
|
<div class="card">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>
|
||||||
|
<a class="btn" href="/portal/jobs">← Zpět na joby</a>
|
||||||
|
<a class="btn btn-secondary" href="{target_url}">Detail targetu</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Souhrn</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Typ</th><td>{html.escape(job.get("type", "") or "")}</td></tr>
|
||||||
|
<tr><th>Status</th><td>{status}</td></tr>
|
||||||
|
<tr><th>Source</th><td>{html.escape(job.get("source", "") or "")}</td></tr>
|
||||||
|
<tr><th>Target</th><td>{html.escape(target_type)}: {target_id_html}</td></tr>
|
||||||
|
<tr><th>Created by</th><td>{html.escape(job.get("created_by_display_name") or job.get("created_by_username") or "")}</td></tr>
|
||||||
|
<tr><th>Created at</th><td>{html.escape(job.get("created_at", "") or "")}</td></tr>
|
||||||
|
<tr><th>Started at</th><td>{html.escape(job.get("started_at", "") or "")}</td></tr>
|
||||||
|
<tr><th>Finished at</th><td>{html.escape(job.get("finished_at", "") or "")}</td></tr>
|
||||||
|
<tr><th>Duration</th><td>{duration}</td></tr>
|
||||||
|
<tr><th>Worker</th><td>{html.escape(job.get("worker_id", "") or "")}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Payload</h2>
|
||||||
|
<pre class="log-viewer log-stdout">{payload}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Result / error</h2>
|
||||||
|
<pre class="log-viewer log-stdout">{result}</pre>
|
||||||
|
<pre class="log-viewer log-stderr">{error_text}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Logy</h2>
|
||||||
|
{log_blocks}
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
@@ -15,6 +15,7 @@ def page(title: str, body: str, user=None) -> str:
|
|||||||
<a href="/portal">Aplikace</a>
|
<a href="/portal">Aplikace</a>
|
||||||
<a href="/portal/new-app">Nová aplikace</a>
|
<a href="/portal/new-app">Nová aplikace</a>
|
||||||
<a href="/portal/deployments">Nasazení</a>
|
<a href="/portal/deployments">Nasazení</a>
|
||||||
|
<a href="/portal/jobs">Joby</a>
|
||||||
<a href="/portal/backups">Zálohy</a>
|
<a href="/portal/backups">Zálohy</a>
|
||||||
<a href="/portal/audit">Audit</a>
|
<a href="/portal/audit">Audit</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user