package http import ( "bufio" "encoding/json" "fmt" stdhttp "net/http" "strings" "github.com/go-chi/chi/v5" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // handleJobLogDownload is GET /api/jobs/{id}/log{.txt,.ndjson}. // // Source of truth is the persisted job_logs table — works any time, // regardless of whether the job is running or already finished. The // download is "everything the server has up to right now"; the live // stream is unaffected (no pause needed). If the operator wants a // fuller snapshot of a still-running job, they hit Download again. // // Format is picked from the URL suffix (.txt | .ndjson) for a // sensible filename in the browser, or the ?format= query param for // REST callers. Default is txt. func (s *Server) handleJobLogDownload(w stdhttp.ResponseWriter, r *stdhttp.Request) { if _, ok := s.requireUser(r); !ok { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } jobID := chi.URLParam(r, "id") if jobID == "" { writeJSONError(w, stdhttp.StatusBadRequest, "missing_job_id", "") return } job, err := s.deps.Store.GetJob(r.Context(), jobID) if err != nil { writeJSONError(w, stdhttp.StatusNotFound, "job_not_found", "") return } format := r.URL.Query().Get("format") if format == "" { // Sniff the URL — chi routes both /log.txt and /log.ndjson here // (or .log if a future route adds it) via the {format} matcher. fmtParam := chi.URLParam(r, "format") switch fmtParam { case "ndjson": format = "ndjson" default: format = "txt" } } logs, err := s.deps.Store.ListJobLogs(r.Context(), jobID, 0, 0) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } short := jobID if len(short) > 8 { short = short[:8] } filename := "job-" + job.Kind + "-" + short switch format { case "ndjson": w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.ndjson"`) writeLogsNDJSON(w, logs) default: w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`.txt"`) writeLogsText(w, job, logs) } } // writeLogsText renders the logs in the same shape the live page shows: // "HH:MM:SS.mmm TAG payload". Adds a small header so the file is // useful as a standalone artefact (operator pastes it into a ticket). func writeLogsText(w stdhttp.ResponseWriter, job *store.Job, logs []store.JobLogLine) { bw := bufio.NewWriter(w) defer func() { _ = bw.Flush() }() _, _ = fmt.Fprintf(bw, "# job %s · kind %s · status %s\n", job.ID, job.Kind, job.Status) if job.StartedAt != nil { _, _ = fmt.Fprintf(bw, "# started %s\n", job.StartedAt.UTC().Format("2006-01-02T15:04:05.000Z")) } if job.FinishedAt != nil { _, _ = fmt.Fprintf(bw, "# finished %s\n", job.FinishedAt.UTC().Format("2006-01-02T15:04:05.000Z")) } _, _ = fmt.Fprintf(bw, "# %d log lines\n\n", len(logs)) for _, l := range logs { tag := streamTag(l.Stream) ts := l.TS.UTC().Format("15:04:05.000") // Strip embedded newlines from payload — log lines should be // single-line, but defensive: a stray '\n' in stderr would // break grep -n. payload := strings.ReplaceAll(l.Payload, "\n", " ") _, _ = fmt.Fprintf(bw, "%s %s %s\n", ts, tag, payload) } } // writeLogsNDJSON emits one JSON object per line. Each object stands // alone — appending to the file remains valid NDJSON. func writeLogsNDJSON(w stdhttp.ResponseWriter, logs []store.JobLogLine) { enc := json.NewEncoder(w) for _, l := range logs { _ = enc.Encode(struct { Seq int64 `json:"seq"` TS string `json:"ts"` Stream string `json:"stream"` Payload string `json:"payload"` }{ Seq: l.Seq, TS: l.TS.UTC().Format("2006-01-02T15:04:05.000Z"), Stream: l.Stream, Payload: l.Payload, }) } } func streamTag(s string) string { switch s { case "stdout": return "OUT" case "stderr": return "ERR" case "event": return "EVENT" } return strings.ToUpper(s) }