// Package admin implements dataset-management HTTP endpoints used by the // stratoflights operator console. // // Endpoints: // // GET /api/v1/admin/datasets list stored datasets // POST /api/v1/admin/datasets trigger a download // DELETE /api/v1/admin/datasets/{name} delete a stored dataset by filename // 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 // GET /api/v1/admin/status service status summary package admin import ( "context" "encoding/json" "net/http" "runtime" "time" "go.uber.org/zap" "predictor-refactored/internal/api/httpjson" "predictor-refactored/internal/datasets" ) // Handler serves all /api/v1/admin/* endpoints. type Handler struct { mgr *datasets.Manager start time.Time 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, start: time.Now().UTC(), log: log} } // Register installs admin routes on mux. 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/{name}", 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) mux.HandleFunc("GET /api/v1/admin/status", h.status) } // datasetDTO is the JSON shape of one stored dataset. type datasetDTO struct { Filename string `json:"filename"` Epoch string `json:"epoch"` Subset *subsetDTO `json:"subset,omitempty"` Coverage *coverageDTO `json:"coverage,omitempty"` Loaded bool `json:"loaded"` } type subsetDTO struct { Region *datasets.Region `json:"region,omitempty"` HourRange *datasets.HourRange `json:"hour_range,omitempty"` Members []int `json:"members,omitempty"` } type coverageDTO struct { Region datasets.Region `json:"region"` StartTime string `json:"start_time"` EndTime string `json:"end_time"` } // listDatasets handles GET /api/v1/admin/datasets. func (h *Handler) listDatasets(w http.ResponseWriter, _ *http.Request) { stored, err := h.mgr.ListEpochs() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } loaded := h.mgr.LoadedDatasets() loadedByName := make(map[string]datasets.LoadedDatasetInfo, len(loaded)) for _, ld := range loaded { loadedByName[ld.ID.Filename()] = ld } out := struct { Source string `json:"source"` Datasets []datasetDTO `json:"datasets"` }{Source: h.mgr.Source(), Datasets: make([]datasetDTO, 0, len(stored))} for _, id := range stored { dto := datasetDTO{ Filename: id.Filename(), Epoch: id.Epoch.UTC().Format(time.RFC3339), } if !id.Subset.IsGlobal() { dto.Subset = &subsetDTO{ Region: id.Subset.Region, HourRange: id.Subset.HourRange, Members: id.Subset.Members, } } if ld, ok := loadedByName[id.Filename()]; ok { dto.Loaded = true dto.Coverage = &coverageDTO{ Region: ld.Coverage.Region, StartTime: ld.Coverage.StartTime.UTC().Format(time.RFC3339), EndTime: ld.Coverage.EndTime.UTC().Format(time.RFC3339), } } out.Datasets = append(out.Datasets, dto) } writeJSON(w, http.StatusOK, out) } // triggerDownload handles POST /api/v1/admin/datasets. // // Body: // {"latest": true} — refresh the latest global dataset // {"epoch": "2026-03-28T06:00:00Z", "subset": {...}} — explicit dataset func (h *Handler) triggerDownload(w http.ResponseWriter, r *http.Request) { var body struct { Epoch string `json:"epoch,omitempty"` Latest bool `json:"latest,omitempty"` Subset *datasets.SubsetSpec `json:"subset,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 } 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 } epoch, err := time.Parse(time.RFC3339, body.Epoch) if err != nil { writeError(w, http.StatusBadRequest, "invalid epoch: "+err.Error()) return } id := datasets.DatasetID{Epoch: epoch.UTC()} if body.Subset != nil { id.Subset = *body.Subset } jobID := h.mgr.Download(id) writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID}) } // deleteDataset handles DELETE /api/v1/admin/datasets/{name}. // // {name} is the dataset filename (DatasetID.Filename()) as returned by GET. func (h *Handler) deleteDataset(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") stored, err := h.mgr.ListEpochs() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } for _, id := range stored { if id.Filename() == name { if err := h.mgr.Remove(id); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) return } } writeError(w, http.StatusNotFound, "dataset not found") } // 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) } // status handles GET /api/v1/admin/status — a consolidated dashboard view. func (h *Handler) status(w http.ResponseWriter, _ *http.Request) { jobs := h.mgr.ListJobs() stored, _ := h.mgr.ListEpochs() loaded := h.mgr.LoadedDatasets() counts := map[string]int{} for _, j := range jobs { counts[string(j.Status)]++ } var mem runtime.MemStats runtime.ReadMemStats(&mem) resp := struct { Source string `json:"source"` Uptime string `json:"uptime"` Goroutines int `json:"goroutines"` MemoryMB uint64 `json:"memory_mb"` JobsByStatus map[string]int `json:"jobs_by_status"` Stored int `json:"stored_datasets"` Loaded int `json:"loaded_datasets"` }{ Source: h.mgr.Source(), Uptime: time.Since(h.start).Round(time.Second).String(), Goroutines: runtime.NumGoroutine(), MemoryMB: mem.Alloc / 1024 / 1024, JobsByStatus: counts, Stored: len(stored), Loaded: len(loaded), } writeJSON(w, http.StatusOK, resp) } type jobDTO struct { ID string `json:"id"` Source string `json:"source"` Dataset string `json:"dataset"` 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, Dataset: j.Dataset.Filename(), Epoch: j.Dataset.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 } var writeJSON = httpjson.Write var writeError = httpjson.Error