diff --git a/packetserver/http/routers/jobs.py b/packetserver/http/routers/jobs.py new file mode 100644 index 0000000..531a105 --- /dev/null +++ b/packetserver/http/routers/jobs.py @@ -0,0 +1,142 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse +from typing import List, Optional, Union, Tuple +from pydantic import BaseModel +from datetime import datetime +import logging +from traceback import format_exc + +from packetserver.http.dependencies import get_current_http_user +from packetserver.http.auth import HttpUser +from packetserver.http.database import DbDependency +from packetserver.server.jobs import Job, JobStatus +from packetserver.http.server import templates + +router = APIRouter(prefix="/api/v1", tags=["jobs"]) +dashboard_router = APIRouter(tags=["jobs"]) + +class JobSummary(BaseModel): + id: int + cmd: Union[str, List[str]] + owner: str + created_at: datetime + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + status: str # JobStatus.name + return_code: int + +class JobDetail(JobSummary): + output: str # base64-encoded + errors: str # base64-encoded + artifacts: List[Tuple[str, str]] # list of (filename, base64_data) + +@router.get("/jobs", response_model=List[JobSummary]) +async def list_user_jobs( + db: DbDependency, + current_user: HttpUser = Depends(get_current_http_user) +): + username = current_user.username.upper().strip() + + try: + with db.transaction() as conn: + root = conn.root() + user_jobs = Job.get_jobs_by_username(username, root) + + # Sort newest first + user_jobs.sort(key=lambda j: j.created_at, reverse=True) + + summaries = [] + for j in user_jobs: + summaries.append(JobSummary( + id=j.id, + cmd=j.cmd, + owner=j.owner, + created_at=j.created_at, + started_at=j.started_at, + finished_at=j.finished_at, + status=j.status.name, + return_code=j.return_code + )) + + except Exception as e: + logging.error(f"Job list failed for {username}: {e}\n{format_exc()}") + raise HTTPException(status_code=500, detail="Failed to list jobs") + + return summaries + +@router.get("/jobs/{jid}", response_model=JobDetail) +async def get_job_detail( + jid: int, + db: DbDependency, + current_user: HttpUser = Depends(get_current_http_user) +): + username = current_user.username.upper().strip() + + try: + with db.transaction() as conn: + root = conn.root() + job = Job.get_job_by_id(jid, root) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + + if job.owner != username: + raise HTTPException(status_code=403, detail="Not authorized to view this job") + + job_dict = job.to_dict(include_data=True, binary_safe=True) + + return JobDetail( + id=job.id, + cmd=job.cmd, + owner=job.owner, + created_at=job.created_at, + started_at=job.started_at, + finished_at=job.finished_at, + status=job.status.name, + return_code=job.return_code, + output=job_dict["output"], + errors=job_dict["errors"], + artifacts=job_dict["artifacts"] + ) + + except HTTPException: + raise + except Exception as e: + logging.error(f"Job detail failed for {username} on {jid}: {e}\n{format_exc()}") + raise HTTPException(status_code=500, detail="Failed to retrieve job") + + return summaries + +@dashboard_router.get("/jobs", response_class=HTMLResponse) +async def jobs_list_page( + request: Request, + db: DbDependency, + current_user: HttpUser = Depends(get_current_http_user) +): + jobs = await list_user_jobs(db=db, current_user=current_user) + + return templates.TemplateResponse( + "jobs.html", + { + "request": request, + "current_user": current_user.username, + "jobs": jobs + } + ) + +@dashboard_router.get("/jobs/{jid}", response_class=HTMLResponse) +async def job_detail_page( + request: Request, + jid: int, + db: DbDependency, + current_user: HttpUser = Depends(get_current_http_user) +): + job = await get_job_detail(jid=jid, db=db, current_user=current_user) + + return templates.TemplateResponse( + "job_detail.html", + { + "request": request, + "current_user": current_user.username, + "job": job + } + ) \ No newline at end of file diff --git a/packetserver/http/server.py b/packetserver/http/server.py index dde86dc..801594a 100644 --- a/packetserver/http/server.py +++ b/packetserver/http/server.py @@ -44,6 +44,8 @@ from .routers.message_detail import router as message_detail_router from .routers.messages import html_router from .routers.objects import router as objects_router from .routers import objects_html +from .routers.jobs import router as jobs_router +from .routers.jobs import dashboard_router as jobs_html_router # initialize database init_db() @@ -60,5 +62,6 @@ app.include_router(message_detail_router) app.include_router(html_router) app.include_router(objects_router) app.include_router(objects_html.router) - +app.include_router(jobs_router) +app.include_router(jobs_html_router) diff --git a/packetserver/http/templates/base.html b/packetserver/http/templates/base.html index eb089dd..f946921 100644 --- a/packetserver/http/templates/base.html +++ b/packetserver/http/templates/base.html @@ -20,6 +20,7 @@ Messages Bulletins Objects + Jobs diff --git a/packetserver/http/templates/job_detail.html b/packetserver/http/templates/job_detail.html new file mode 100644 index 0000000..ebfd4b2 --- /dev/null +++ b/packetserver/http/templates/job_detail.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} + +{% block title %}Job {{ job.id }} - {{ job.cmd|truncate(50) }}{% endblock %} + +{% block content %} +

Job #{{ job.id }}

+ +
+
+
+
+ Command +
+
+
{% if job.cmd is string %}{{ job.cmd }}{% else %}{{ job.cmd|join(' ') }}{% endif %}
+
+
+ + {% if job.output or job.errors %} +
+
+ Output & Errors +
+ +
+ {% if job.output %} +
+
{{ job.output | b64decode | forceescape }}
+
+ {% endif %} + {% if job.errors %} +
+
{{ job.errors | b64decode | forceescape }}
+
+ {% endif %} +
+
+ {% endif %} + + {% if job.artifacts %} +
+
Artifacts
+
+
    + {% for name, b64 in job.artifacts %} +
  • + {{ name }} + Download +
  • + {% endfor %} +
+
+
+ {% endif %} +
+ +
+
+
Job Details
+
    +
  • Status: + {% if job.status == "QUEUED" %}Queued + {% elif job.status == "RUNNING" %}Running + {% elif job.status == "COMPLETED" %}Completed + {% elif job.status == "FAILED" %}Failed + {% elif job.status == "CANCELLED" %}Cancelled + {% endif %} +
  • +
  • Owner: {{ job.owner }}
  • +
  • Created: {{ job.created_at.strftime('%b %d, %Y %H:%M') }}
  • +
  • Started: {% if job.started_at %}{{ job.started_at.strftime('%H:%M:%S') }}{% else %}-{% endif %}
  • +
  • Finished: {% if job.finished_at %}{{ job.finished_at.strftime('%H:%M:%S') }}{% else %}-{% endif %}
  • +
  • Return Code: {% if job.return_code is not none %}{{ job.return_code }}{% else %}-{% endif %}
  • +
+
+
+
+ +

+ ← Back to Jobs +

+{% endblock %} \ No newline at end of file diff --git a/packetserver/http/templates/jobs.html b/packetserver/http/templates/jobs.html new file mode 100644 index 0000000..24b00dc --- /dev/null +++ b/packetserver/http/templates/jobs.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} + +{% block title %}My Jobs - {{ current_user }}{% endblock %} + +{% block content %} +

My Jobs

+ +{% if jobs %} + + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + + {% endfor %} + +
IDCommandStatusCreatedStartedFinishedReturn Code
{{ job.id }} + + {% if job.cmd is string %} + {{ job.cmd[:80] }}{% if job.cmd|length > 80 %}...{% endif %} + {% else %} + {{ job.cmd|join(' ')[:80] }}{% if job.cmd|join(' ')|length > 80 %}...{% endif %} + {% endif %} + + + {% if job.status == "QUEUED" %} + Queued + {% elif job.status == "RUNNING" %} + Running + {% elif job.status == "COMPLETED" %} + Completed + {% elif job.status == "FAILED" %} + Failed + {% elif job.status == "CANCELLED" %} + Cancelled + {% endif %} + {{ job.created_at.strftime('%b %d, %Y %H:%M') }}{% if job.started_at %}{{ job.started_at.strftime('%H:%M') }}{% else %}-{% endif %}{% if job.finished_at %}{{ job.finished_at.strftime('%H:%M') }}{% else %}-{% endif %} + {% if job.return_code is not none %} + {{ job.return_code }} + {% else %} + - + {% endif %} +
+{% else %} +
+ No jobs yet. When you run containerized tasks (e.g., packet processing scripts), they'll appear here. +
+{% endif %} + + +{% endblock %} \ No newline at end of file