Jobs dashboard additions.
This commit is contained in:
@@ -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:
|
||||||
@@ -201,3 +205,57 @@ async def create_job(
|
|||||||
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)
|
||||||
@@ -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>
|
||||||
|
|||||||
54
packetserver/http/templates/job_new.html
Normal file
54
packetserver/http/templates/job_new.html
Normal 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 %}
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user