// Package admin implements dataset-management HTTP endpoints used by the // stratoflights operator console. // // Endpoints: // // GET /api/v1/admin/datasets list stored epochs // POST /api/v1/admin/datasets trigger a download // DELETE /api/v1/admin/datasets/{epoch} delete a stored epoch // GET /api/v1/admin/jobs list all jobs // GET /api/v1/admin/jobs/{id} fetch one job // DELETE /api/v1/admin/jobs/{id} cancel a running job package admin import ( "context" "encoding/json" "net/http" "time" "go.uber.org/zap" "predictor-refactored/internal/datasets" ) // Handler serves all /api/v1/admin/* endpoints. type Handler struct { mgr *datasets.Manager log *zap.Logger } // New wires an admin handler. func New(mgr *datasets.Manager, log *zap.Logger) *Handler { if log == nil { log = zap.NewNop() } return &Handler{mgr: mgr, log: log} } // Register installs admin routes on mux. Routes are mounted under // /api/v1/admin/... func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/admin/datasets", h.listDatasets) mux.HandleFunc("POST /api/v1/admin/datasets", h.triggerDownload) mux.HandleFunc("DELETE /api/v1/admin/datasets/{epoch}", h.deleteDataset) mux.HandleFunc("GET /api/v1/admin/jobs", h.listJobs) mux.HandleFunc("GET /api/v1/admin/jobs/{id}", h.getJob) mux.HandleFunc("DELETE /api/v1/admin/jobs/{id}", h.cancelJob) } // listDatasets handles GET /api/v1/admin/datasets. func (h *Handler) listDatasets(w http.ResponseWriter, _ *http.Request) { epochs, err := h.mgr.ListEpochs() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } active := "" if a := h.mgr.Active(); a != nil { active = a.Epoch().UTC().Format(time.RFC3339) } out := struct { Source string `json:"source"` Active string `json:"active,omitempty"` Epochs []string `json:"epochs"` }{ Source: h.mgr.Source(), Active: active, } for _, e := range epochs { out.Epochs = append(out.Epochs, e.UTC().Format(time.RFC3339)) } writeJSON(w, http.StatusOK, out) } // triggerDownload handles POST /api/v1/admin/datasets. // // Body: {"epoch": "2026-03-28T06:00:00Z"} OR {"latest": true}. func (h *Handler) triggerDownload(w http.ResponseWriter, r *http.Request) { var body struct { Epoch string `json:"epoch,omitempty"` Latest bool `json:"latest,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid body: "+err.Error()) return } if !body.Latest && body.Epoch == "" { writeError(w, http.StatusBadRequest, "specify either epoch or latest=true") return } var epoch time.Time if body.Latest { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() jobID, err := h.mgr.Refresh(ctx, 0) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID}) return } var err error epoch, err = time.Parse(time.RFC3339, body.Epoch) if err != nil { writeError(w, http.StatusBadRequest, "invalid epoch: "+err.Error()) return } jobID := h.mgr.Download(epoch) writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID}) } // deleteDataset handles DELETE /api/v1/admin/datasets/{epoch}. func (h *Handler) deleteDataset(w http.ResponseWriter, r *http.Request) { rawEpoch := r.PathValue("epoch") epoch, err := time.Parse(time.RFC3339, rawEpoch) if err != nil { writeError(w, http.StatusBadRequest, "invalid epoch: "+err.Error()) return } if err := h.mgr.RemoveEpoch(epoch); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // listJobs handles GET /api/v1/admin/jobs. func (h *Handler) listJobs(w http.ResponseWriter, _ *http.Request) { jobs := h.mgr.ListJobs() out := make([]jobDTO, 0, len(jobs)) for _, j := range jobs { out = append(out, toDTO(j)) } writeJSON(w, http.StatusOK, out) } // getJob handles GET /api/v1/admin/jobs/{id}. func (h *Handler) getJob(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") job, ok := h.mgr.GetJob(id) if !ok { writeError(w, http.StatusNotFound, "job not found") return } writeJSON(w, http.StatusOK, toDTO(job)) } // cancelJob handles DELETE /api/v1/admin/jobs/{id}. func (h *Handler) cancelJob(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !h.mgr.CancelJob(id) { writeError(w, http.StatusConflict, "job not found or already terminal") return } w.WriteHeader(http.StatusNoContent) } type jobDTO struct { ID string `json:"id"` Source string `json:"source"` Epoch string `json:"epoch"` Status string `json:"status"` StartedAt string `json:"started_at"` EndedAt string `json:"ended_at,omitempty"` Err string `json:"error,omitempty"` Total int `json:"total_units"` Done int `json:"done_units"` Bytes int64 `json:"bytes"` } func toDTO(j datasets.JobInfo) jobDTO { dto := jobDTO{ ID: j.ID, Source: j.Source, Epoch: j.Epoch.UTC().Format(time.RFC3339), Status: string(j.Status), StartedAt: j.StartedAt.UTC().Format(time.RFC3339), Err: j.Err, Total: j.Total, Done: j.Done, Bytes: j.Bytes, } if j.EndedAt != nil { dto.EndedAt = j.EndedAt.UTC().Format(time.RFC3339) } return dto } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func writeError(w http.ResponseWriter, status int, description string) { writeJSON(w, status, map[string]any{ "error": map[string]string{ "type": http.StatusText(status), "description": description, }, }) }