493 lines
14 KiB
Go
493 lines
14 KiB
Go
package ui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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("POST /targets/{id}/delete", s.handleDeleteTarget)
|
|
mux.HandleFunc("DELETE /targets/{id}", s.handleDeleteTarget)
|
|
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)
|
|
mux.HandleFunc("POST /api/maintenance/stale-expire", s.handleMaintenanceStaleExpire)
|
|
mux.HandleFunc("GET /api/maintenance/stats", s.handleMaintenanceStats)
|
|
}
|
|
|
|
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++
|
|
}
|
|
}
|
|
}
|
|
|
|
mStats, err := s.db.GetJobMaintenanceStats()
|
|
if err != nil {
|
|
logger.Log.Warnw("Failed to get maintenance stats", "component", "UI", "error", err)
|
|
}
|
|
|
|
data := PageData{
|
|
Targets: targets,
|
|
Jobs: jobs,
|
|
Applications: apps,
|
|
MaintenanceStats: mStats,
|
|
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, true)
|
|
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) handleDeleteTarget(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}/delete", "component", "API", "target_id", id)
|
|
|
|
deletedTarget, deletedJobsCount, err := s.db.DeleteTarget(id)
|
|
if err != nil {
|
|
logger.Log.Errorw("Failed to delete target", "component", "API", "target_id", id, "error", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
logger.Log.Infow("Target deleted successfully", "component", "API", "target_id", id, "name", deletedTarget.Name, "deleted_jobs", deletedJobsCount)
|
|
|
|
// Also remove from targets.yaml if target_config is configured
|
|
if s.cfg != nil && s.cfg.TargetConfig != "" && deletedTarget != nil {
|
|
if err := db.RemoveTargetFromYAML(s.cfg.TargetConfig, deletedTarget.TargetURL); err != nil {
|
|
logger.Log.Warnw("Could not remove target from targets.yaml", "component", "API", "error", err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
job, err := s.db.GetJobByID(id)
|
|
if err == nil && job != nil {
|
|
filterScore := 0.0
|
|
score := 0.0
|
|
if job.Analysis != nil {
|
|
filterScore = job.Analysis.FilterScore
|
|
score = job.Analysis.Score
|
|
}
|
|
_ = s.db.CreateJobAnalysis(id, filterScore, score, true, "PENDING")
|
|
}
|
|
|
|
// Enqueue analyze task
|
|
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.5 py-1 bg-amber-500/20 text-amber-300 border border-amber-500/30 rounded-lg font-semibold">🔄 AI 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)
|
|
}
|
|
|
|
func (s *Server) handleMaintenanceStaleExpire(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
days, _ := strconv.Atoi(r.FormValue("days"))
|
|
if days <= 0 {
|
|
days = 14
|
|
}
|
|
protect := r.FormValue("protect") != "false"
|
|
|
|
count, err := s.db.EnqueueExpireTasks(days, protect)
|
|
if err != nil {
|
|
logger.Log.Errorw("Failed to enqueue expire tasks", "component", "UI", "error", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"count": count,
|
|
"message": fmt.Sprintf("Enqueued %d expire task(s) for postings not seen in %d+ days.", count, days),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleMaintenanceStats(w http.ResponseWriter, r *http.Request) {
|
|
stats, err := s.db.GetJobMaintenanceStats()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(stats)
|
|
}
|