Files
hireme/internal/ui/handlers.go
T

406 lines
11 KiB
Go

package ui
import (
"encoding/json"
"net/http"
"strconv"
"github.com/hireme/hireme/internal/config"
"github.com/hireme/hireme/internal/db"
"github.com/hireme/hireme/internal/logger"
"github.com/hireme/hireme/internal/worker"
)
type Server struct {
cfg *config.Config
db *db.DB
pool *worker.WorkerPool
}
func NewServer(cfg *config.Config, database *db.DB, pool *worker.WorkerPool) *Server {
return &Server{
cfg: cfg,
db: database,
pool: pool,
}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", s.handleIndex)
mux.HandleFunc("GET /targets", s.handleGetTargets)
mux.HandleFunc("POST /targets", s.handleCreateTarget)
mux.HandleFunc("POST /targets/{id}/toggle", s.handleToggleTarget)
mux.HandleFunc("GET /jobs", s.handleGetJobs)
mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail)
mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob)
mux.HandleFunc("POST /jobs/{id}/opt-out", s.handleOptOut)
mux.HandleFunc("GET /applications", s.handleGetApplications)
mux.HandleFunc("POST /applications", s.handleCreateApplication)
mux.HandleFunc("PATCH /applications/{id}/status", s.handleUpdateAppStatus)
mux.HandleFunc("POST /api/tasks/discover", s.handleDiscoverTasks)
mux.HandleFunc("POST /api/tasks/clean", s.handleCleanTasks)
mux.HandleFunc("POST /api/tasks/reprocess", s.handleReprocessJobs)
mux.HandleFunc("POST /api/tasks/toggle", s.handleToggleTasks)
mux.HandleFunc("GET /api/status", s.handleGetStatus)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
targets, err := s.db.GetAllTargets()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
jobs, err := s.db.GetJobsWithAnalysis()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
apps, err := s.db.GetApplications()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
toReviewCount := 0
inPipelineCount := 0
for _, j := range jobs {
if j.App != nil && j.App.Status != "OPTED_OUT" {
inPipelineCount++
} else if j.App == nil || j.App.Status != "OPTED_OUT" {
if j.Analysis != nil && (j.Analysis.IsQualified || j.Analysis.Status == "PENDING") {
toReviewCount++
}
}
}
data := PageData{
Targets: targets,
Jobs: jobs,
Applications: apps,
ActiveTab: "triage",
LLMProvider: s.cfg.LLMProvider,
ToReviewCount: toReviewCount,
TotalIngestedCount: len(jobs),
InPipelineCount: inPipelineCount,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := RenderPage(w, data); err != nil {
logger.Log.Errorw("RenderPage error", "component", "UI", "error", err)
}
}
func (s *Server) handleGetTargets(w http.ResponseWriter, r *http.Request) {
targets, err := s.db.GetAllTargets()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderTargetList(w, targets)
}
func (s *Server) handleCreateTarget(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := r.FormValue("name")
targetURL := r.FormValue("target_url")
platform := r.FormValue("platform")
if name == "" || targetURL == "" {
http.Error(w, "name and target_url are required", http.StatusBadRequest)
return
}
logger.Log.Infow("Triggered /targets (Create)", "component", "API", "name", name, "platform", platform)
_, err := s.db.CreateTarget(name, targetURL, platform)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.handleGetTargets(w, r)
}
func (s *Server) handleToggleTarget(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid target id", http.StatusBadRequest)
return
}
logger.Log.Infow("Triggered /targets/{id}/toggle", "component", "API", "target_id", id)
if err := s.db.ToggleTargetStatus(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.handleGetTargets(w, r)
}
func (s *Server) handleGetJobs(w http.ResponseWriter, r *http.Request) {
jobs, err := s.db.GetJobsWithAnalysis()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderJobList(w, jobs)
}
func (s *Server) handleGetJobDetail(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid job id", http.StatusBadRequest)
return
}
job, err := s.db.GetJobByID(id)
if err != nil {
http.Error(w, "job not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderJobDetail(w, job)
}
func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid job id", http.StatusBadRequest)
return
}
logger.Log.Infow("Triggered /jobs/{id}/analyze", "component", "API", "job_id", id)
// Enqueue analyze task instead of synchronous processing
payload := worker.AnalyzePayload{RawJobID: int64(id)}
err = s.db.EnqueueTask("ANALYZE", payload)
if err != nil {
http.Error(w, "Failed to enqueue analysis task: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`<span class="text-xs px-2 py-1 bg-yellow-100 text-yellow-800 rounded">Analysis Queued</span>`))
}
func (s *Server) handleOptOut(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid job id", http.StatusBadRequest)
return
}
logger.Log.Infow("Triggered /jobs/{id}/opt-out", "component", "API", "job_id", id)
_, err = s.db.CreateOrUpdateApplication(id, "OPTED_OUT", "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
jobs, err := s.db.GetJobsWithAnalysis()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderJobList(w, jobs)
}
func (s *Server) handleGetApplications(w http.ResponseWriter, r *http.Request) {
apps, err := s.db.GetApplications()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderApplyTable(w, apps)
}
func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rawJobIDStr := r.FormValue("raw_job_id")
status := r.FormValue("status")
if status == "" {
status = "SAVED"
}
rawJobID, err := strconv.ParseInt(rawJobIDStr, 10, 64)
if err != nil {
http.Error(w, "invalid raw_job_id", http.StatusBadRequest)
return
}
logger.Log.Infow("Triggered /applications (Create)", "component", "API", "raw_job_id", rawJobID, "status", status)
_, err = s.db.CreateOrUpdateApplication(rawJobID, status, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Always return the updated job list so the action column refreshes
s.handleGetJobs(w, r)
}
func (s *Server) handleUpdateAppStatus(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
appID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid app id", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newStatus := r.FormValue("status")
if newStatus == "" {
http.Error(w, "status required", http.StatusBadRequest)
return
}
logger.Log.Infow("Triggered /applications/{id}/status (Update)", "component", "API", "app_id", appID, "new_status", newStatus)
// Fetch rawJobID for appID
apps, err := s.db.GetApplications()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var targetRawJobID int64 = -1
for _, a := range apps {
if a.App != nil && a.App.ID == appID {
targetRawJobID = a.ID
break
}
}
if targetRawJobID == -1 {
http.Error(w, "application not found", http.StatusNotFound)
return
}
_, err = s.db.CreateOrUpdateApplication(targetRawJobID, newStatus, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
updatedApps, _ := s.db.GetApplications()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderApplyTable(w, updatedApps)
}
func (s *Server) handleDiscoverTasks(w http.ResponseWriter, r *http.Request) {
logger.Log.Infow("Triggered /api/tasks/discover", "component", "API")
targets, err := s.db.GetAllTargets()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
count := 0
for _, target := range targets {
if !target.IsActive {
continue
}
payload := worker.DiscoverPayload{
TargetID: target.ID,
TargetURL: target.TargetURL,
Platform: target.Platform,
}
if err := s.db.EnqueueTask("DISCOVER", payload); err == nil {
count++
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status": "success", "message": "Queued ` + strconv.Itoa(count) + ` discovery tasks"}`))
}
func (s *Server) handleCleanTasks(w http.ResponseWriter, r *http.Request) {
logger.Log.Infow("Triggered /api/tasks/clean", "component", "API")
err := s.db.ClearTaskQueue()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status": "success"}`))
}
func (s *Server) handleReprocessJobs(w http.ResponseWriter, r *http.Request) {
logger.Log.Infow("Triggered /api/tasks/reprocess", "component", "API")
if err := s.db.ReprocessAllJobs(); err != nil {
logger.Log.Errorw("Failed to reprocess jobs", "component", "API", "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status": "success", "message": "All jobs queued for reprocessing"}`))
}
func (s *Server) handleToggleTasks(w http.ResponseWriter, r *http.Request) {
logger.Log.Infow("Triggered /api/tasks/toggle", "component", "API")
isRunning := s.pool.Toggle()
status := "paused"
if isRunning {
status = "running"
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status": "` + status + `"}`))
}
// handleGetStatus returns a JSON snapshot of worker pool state and task queue counts.
// Used by the bottom bar to auto-refresh every few seconds without a full page reload.
func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) {
workerStatus := "paused"
if s.pool.IsRunning() {
workerStatus = "running"
}
stats, err := s.db.GetQueueStats()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := map[string]interface{}{
"worker_status": workerStatus,
"queue": stats,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}