Files
ServerKit/backend/app/api/jobs.py
T
Juan DenisandClaude Opus 4.8 634f21340d feat(jobs): host-idiom Jobs page + server-side search/paging + job retention
Frontend: rebuild /jobs to the Servers/Domains table-with-search idiom —
SegControl status filter, kind Select, debounced search, DataTable rows,
clickable compact KPIs, Prev/Next paging with 'Showing X-Y of N'. Rewrite
_jobs.scss for the sk-jobs namespace (dead .jobs-* selectors removed).

Backend: GET /api/v1/jobs gains a case-insensitive `q` (kind/owner) filter
and a `total` count; JobService.prune_terminal + a builtin.job_retention
ScheduledJob prune scheduler-tick jobs (succeeded/cancelled > jobs.retention_days
default 14, failed > 3x) so the '107k Total' stops growing at the source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:01:12 -04:00

127 lines
4.1 KiB
Python

"""Read/ops API for the unified job system — the single pane to observe all
background work. Admin-gated, mirroring app/api/queue_bus.py conventions."""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.jobs import registry
from app.jobs.service import JobService, ScheduledJobService
jobs_bp = Blueprint('jobs', __name__)
def _require_admin():
from app.models.user import User
user = User.query.get(get_jwt_identity())
if not user or not user.is_admin:
return None
return user
# --- Static routes first; Werkzeug ranks these above the dynamic /<job_id>. ---
@jobs_bp.route('', methods=['GET'])
@jobs_bp.route('/', methods=['GET'])
@jwt_required()
def list_jobs():
if not _require_admin():
return jsonify({'error': 'Admin access required'}), 403
try:
limit = min(int(request.args.get('limit', 50)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
except (TypeError, ValueError):
limit, offset = 50, 0
filters = {
'status': request.args.get('status'),
'kind': request.args.get('kind'),
'owner_type': request.args.get('owner_type'),
'owner_id': request.args.get('owner_id'),
'q': request.args.get('q'),
}
jobs = JobService.list(limit=limit, offset=offset, **filters)
total = JobService.count(**filters)
return jsonify({
'jobs': [j.to_dict() for j in jobs],
'total': total,
'limit': limit,
'offset': offset,
})
@jobs_bp.route('/stats', methods=['GET'])
@jwt_required()
def job_stats():
if not _require_admin():
return jsonify({'error': 'Admin access required'}), 403
return jsonify(JobService.stats())
@jobs_bp.route('/kinds', methods=['GET'])
@jwt_required()
def job_kinds():
if not _require_admin():
return jsonify({'error': 'Admin access required'}), 403
return jsonify({'kinds': registry.registered_kinds()})
@jobs_bp.route('/scheduled', methods=['GET'])
@jwt_required()
def list_scheduled():
if not _require_admin():
return jsonify({'error': 'Admin access required'}), 403
return jsonify({'scheduled': [s.to_dict() for s in ScheduledJobService.list()]})
@jobs_bp.route('/scheduled/<int:scheduled_id>/run', methods=['POST'])
@jwt_required()
def run_scheduled(scheduled_id):
if not _require_admin():
return jsonify({'error': 'Admin access required'}), 403
job = ScheduledJobService.run_now(scheduled_id)
if not job:
return jsonify({'error': 'Scheduled job not found'}), 404
return jsonify({'job': job.to_dict()})
@jobs_bp.route('/scheduled/<int:scheduled_id>/enabled', methods=['POST'])
@jwt_required()
def toggle_scheduled(scheduled_id):
if not _require_admin():
return jsonify({'error': 'Admin access required'}), 403
body = request.get_json(silent=True) or {}
scheduled = ScheduledJobService.set_enabled(scheduled_id, bool(body.get('enabled', True)))
if not scheduled:
return jsonify({'error': 'Scheduled job not found'}), 404
return jsonify({'scheduled': scheduled.to_dict()})
@jobs_bp.route('/<job_id>', methods=['GET'])
@jwt_required()
def get_job(job_id):
if not _require_admin():
return jsonify({'error': 'Admin access required'}), 403
job = JobService.get(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
return jsonify({'job': job.to_dict(include_payload=True)})
@jobs_bp.route('/<job_id>/cancel', methods=['POST'])
@jwt_required()
def cancel_job(job_id):
if not _require_admin():
return jsonify({'error': 'Admin access required'}), 403
job = JobService.cancel(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
return jsonify({'job': job.to_dict()})
@jobs_bp.route('/<job_id>/retry', methods=['POST'])
@jwt_required()
def retry_job(job_id):
if not _require_admin():
return jsonify({'error': 'Admin access required'}), 403
job = JobService.retry(job_id)
if not job:
return jsonify({'error': 'Job not found'}), 404
return jsonify({'job': job.to_dict()})