Jobs dashboard additions.

This commit is contained in:
Michael Woods
2025-12-26 16:03:06 -05:00
parent ec0cb0ce45
commit c060ddb060
4 changed files with 136 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request, Form, UploadFile, File
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from typing import List, Optional, Union, Tuple, Dict, Any from typing import List, Optional, Union, Tuple, Dict, Any
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime
@@ -26,11 +26,13 @@ class JobSummary(BaseModel):
finished_at: Optional[datetime] = None finished_at: Optional[datetime] = None
status: str # JobStatus.name status: str # JobStatus.name
return_code: int return_code: int
env: Dict[str, str] = {}
class JobDetail(JobSummary): class JobDetail(JobSummary):
output: str # base64-encoded output: str # base64-encoded
errors: str # base64-encoded errors: str # base64-encoded
artifacts: List[Tuple[str, str]] # list of (filename, base64_data) artifacts: List[Tuple[str, str]] # list of (filename, base64_data)
env: Dict[str, str] = {}
from typing import Dict from typing import Dict
@@ -64,7 +66,8 @@ async def list_user_jobs(
started_at=j.started_at, started_at=j.started_at,
finished_at=j.finished_at, finished_at=j.finished_at,
status=j.status.name, status=j.status.name,
return_code=j.return_code return_code=j.return_code,
env=j.env
)) ))
except Exception as e: except Exception as e:
@@ -104,7 +107,8 @@ async def get_job_detail(
return_code=job.return_code, return_code=job.return_code,
output=job_dict["output"], output=job_dict["output"],
errors=job_dict["errors"], errors=job_dict["errors"],
artifacts=job_dict["artifacts"] artifacts=job_dict["artifacts"],
env=job_dict.get("env", {})
) )
except HTTPException: except HTTPException:
@@ -200,4 +204,58 @@ async def create_job(
raise raise
except Exception as e: except Exception as e:
logging.error(f"Job creation failed for {username}: {e}\n{format_exc()}") logging.error(f"Job creation failed for {username}: {e}\n{format_exc()}")
raise HTTPException(status_code=500, detail="Failed to queue job") raise HTTPException(status_code=500, detail="Failed to queue job")
@dashboard_router.get("/jobs/new", response_class=HTMLResponse)
async def new_job_form(
request: Request,
current_user: HttpUser = Depends(get_current_http_user)
):
return templates.TemplateResponse(
"job_new.html",
{
"request": request,
"current_user": current_user.username
}
)
@dashboard_router.post("/jobs/new")
async def create_job_from_form(
db: DbDependency,
request: Request,
cmd: str = Form(...),
env_keys: List[str] = Form(default=[]),
env_values: List[str] = Form(default=[]),
files: List[UploadFile] = File(default=[]),
current_user: HttpUser = Depends(get_current_http_user)
):
# Build env dict from parallel lists
env = {}
for k, v in zip(env_keys, env_values):
if k.strip():
env[k.strip()] = v.strip()
# Build files dict for API (filename → base64)
files_dict = {}
for upload in files:
if upload.filename:
content = await upload.read()
files_dict[upload.filename] = base64.b64encode(content).decode('ascii')
# Prepare payload for the existing API
payload = {
"cmd": [part.strip() for part in cmd.split() if part.strip()], # split on whitespace, like shell
"env": env if env else None,
"files": files_dict if files_dict else None
}
# Call the API internally
from packetserver.http.routers.jobs import create_job as api_create_job
response = await api_create_job(
payload=JobCreate(**{k: v for k, v in payload.items() if v is not None}),
db=db,
current_user=current_user
)
# Redirect to the new job detail page
return RedirectResponse(url=f"/jobs/{response.id}", status_code=303)

View File

@@ -90,6 +90,18 @@
<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>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>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> <li class="list-group-item"><strong>Return Code:</strong> {% if job.return_code is not none %}{{ job.return_code }}{% else %}-{% endif %}</li>
<li class="list-group-item">
<strong>Environment Variables:</strong>
{% if job.env %}
<ul class="list-unstyled mt-2 mb-0">
{% for key, value in job.env.items() %}
<li><code>{{ key }}={{ value }}</code></li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}New Job - {{ current_user }}{% endblock %}
{% block content %}
<h2 class="mb-4">Queue New Job</h2>
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">Command</label>
<textarea name="cmd" class="form-control" rows="3" placeholder="e.g. python script.py --input data.txt" required></textarea>
<div class="form-text">Space-separated command and arguments (like a shell command).</div>
</div>
<div class="mb-3">
<label class="form-label">Environment Variables</label>
<div id="env-fields">
<div class="row mb-2 env-row">
<div class="col"><input type="text" name="env_keys" class="form-control" placeholder="KEY"></div>
<div class="col"><input type="text" name="env_values" class="form-control" placeholder="value"></div>
<div class="col-auto"><button type="button" class="btn btn-outline-danger btn-sm" onclick="this.parentElement.parentElement.remove()">Remove</button></div>
</div>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="addEnvRow()">+ Add Env Var</button>
</div>
<div class="mb-3">
<label class="form-label">Files (scripts, config, data)</label>
<input type="file" name="files" class="form-control" multiple>
<div class="form-text">Uploaded files will be available in the container's working directory.</div>
</div>
<button type="submit" class="btn btn-primary me-2">Queue Job</button>
<a href="/jobs" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
<script>
function addEnvRow() {
const container = document.getElementById('env-fields');
const row = document.createElement('div');
row.className = 'row mb-2 env-row';
row.innerHTML = `
<div class="col"><input type="text" name="env_keys" class="form-control" placeholder="KEY"></div>
<div class="col"><input type="text" name="env_values" class="form-control" placeholder="value"></div>
<div class="col-auto"><button type="button" class="btn btn-outline-danger btn-sm" onclick="this.parentElement.parentElement.remove()">Remove</button></div>
`;
container.appendChild(row);
}
</script>
{% endblock %}

View File

@@ -3,7 +3,13 @@
{% block title %}My Jobs - {{ current_user }}{% endblock %} {% block title %}My Jobs - {{ current_user }}{% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4">My Jobs</h2>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">My Jobs</h2>
<a href="/jobs/new" class="btn btn-success">
<i class="bi bi-plus-lg"></i> New Job
</a>
</div>
{% if jobs %} {% if jobs %}
<table class="table table-striped table-hover"> <table class="table table-striped table-hover">