Trying fixes.
This commit is contained in:
142
packetserver/http/routers/jobs.py
Normal file
142
packetserver/http/routers/jobs.py
Normal file
@@ -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
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<a href="/messages" class="btn btn-outline-light btn-sm me-2">Messages</a>
|
||||
<a href="/bulletins" class="btn btn-outline-light btn-sm me-2">Bulletins</a>
|
||||
<a href="/objects" class="btn btn-outline-light btn-sm me-2">Objects</a>
|
||||
<a href="/jobs" class="btn btn-outline-light btn-sm me-2">Jobs</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
93
packetserver/http/templates/job_detail.html
Normal file
93
packetserver/http/templates/job_detail.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Job {{ job.id }} - {{ job.cmd|truncate(50) }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Job #{{ job.id }}</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
Command
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre><code>{% if job.cmd is string %}{{ job.cmd }}{% else %}{{ job.cmd|join(' ') }}{% endif %}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if job.output or job.errors %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
Output & Errors
|
||||
</div>
|
||||
<ul class="nav nav-tabs card-header-tabs">
|
||||
{% if job.output %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#output">Stdout</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if job.errors %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if not job.output %} active{% endif %}" data-bs-toggle="tab" href="#errors">Stderr</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="card-body tab-content">
|
||||
{% if job.output %}
|
||||
<div class="tab-pane fade show active" id="output">
|
||||
<pre><code>{{ job.output | b64decode | forceescape }}</code></pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if job.errors %}
|
||||
<div class="tab-pane fade{% if job.output %} show{% else %} show active{% endif %}" id="errors">
|
||||
<pre><code>{{ job.errors | b64decode | forceescape }}</code></pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if job.artifacts %}
|
||||
<div class="card">
|
||||
<div class="card-header">Artifacts</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group">
|
||||
{% for name, b64 in job.artifacts %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
{{ name }}
|
||||
<a href="data:application/octet-stream;base64,{{ b64 }}" download="{{ name }}" class="btn btn-sm btn-primary">Download</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">Job Details</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item"><strong>Status:</strong>
|
||||
{% if job.status == "QUEUED" %}<span class="badge bg-secondary">Queued</span>
|
||||
{% elif job.status == "RUNNING" %}<span class="badge bg-primary">Running</span>
|
||||
{% elif job.status == "COMPLETED" %}<span class="badge bg-success">Completed</span>
|
||||
{% elif job.status == "FAILED" %}<span class="badge bg-danger">Failed</span>
|
||||
{% elif job.status == "CANCELLED" %}<span class="badge bg-warning">Cancelled</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="list-group-item"><strong>Owner:</strong> {{ job.owner }}</li>
|
||||
<li class="list-group-item"><strong>Created:</strong> {{ job.created_at.strftime('%b %d, %Y %H:%M') }}</li>
|
||||
<li class="list-group-item"><strong>Started:</strong> {% if job.started_at %}{{ job.started_at.strftime('%H:%M:%S') }}{% else %}-{% endif %}</li>
|
||||
<li class="list-group-item"><strong>Finished:</strong> {% if job.finished_at %}{{ job.finished_at.strftime('%H:%M:%S') }}{% else %}-{% endif %}</li>
|
||||
<li class="list-group-item"><strong>Return Code:</strong> {% if job.return_code is not none %}{{ job.return_code }}{% else %}-{% endif %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4">
|
||||
<a href="/jobs">← Back to Jobs</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
72
packetserver/http/templates/jobs.html
Normal file
72
packetserver/http/templates/jobs.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Jobs - {{ current_user }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">My Jobs</h2>
|
||||
|
||||
{% if jobs %}
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Command</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
<th>Return Code</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr class="clickable-row" style="cursor: pointer;" onclick="window.location='/jobs/{{ job.id }}'">
|
||||
<td>{{ job.id }}</td>
|
||||
<td>
|
||||
<code>
|
||||
{% 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 %}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
{% if job.status == "QUEUED" %}
|
||||
<span class="badge bg-secondary">Queued</span>
|
||||
{% elif job.status == "RUNNING" %}
|
||||
<span class="badge bg-primary">Running</span>
|
||||
{% elif job.status == "COMPLETED" %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% elif job.status == "FAILED" %}
|
||||
<span class="badge bg-danger">Failed</span>
|
||||
{% elif job.status == "CANCELLED" %}
|
||||
<span class="badge bg-warning">Cancelled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ job.created_at.strftime('%b %d, %Y %H:%M') }}</td>
|
||||
<td>{% if job.started_at %}{{ job.started_at.strftime('%H:%M') }}{% else %}-{% endif %}</td>
|
||||
<td>{% if job.finished_at %}{{ job.finished_at.strftime('%H:%M') }}{% else %}-{% endif %}</td>
|
||||
<td>
|
||||
{% if job.return_code is not none %}
|
||||
<code>{{ job.return_code }}</code>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
No jobs yet. When you run containerized tasks (e.g., packet processing scripts), they'll appear here.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.clickable-row:hover {
|
||||
background-color: rgba(0,123,255,0.1);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user