package api import ( "database/sql" "fmt" "net/http" "strconv" "strings" "time" authpkg "jiggablend/internal/auth" "github.com/go-chi/chi/v5" ) type uiJobSummary struct { ID int64 Name string Status string Progress float64 FrameStart *int FrameEnd *int OutputFormat *string CreatedAt time.Time } type uiTaskSummary struct { ID int64 TaskType string Status string Frame int FrameEnd *int CurrentStep string RetryCount int Error string StartedAt *time.Time CompletedAt *time.Time } type uiFileSummary struct { ID int64 FileName string FileType string FileSize int64 CreatedAt time.Time } func (s *Manager) setupUIRoutes() { s.router.Get("/", s.handleUIRoot) s.router.Get("/login", s.handleUILoginPage) s.router.Post("/logout", s.handleUILogout) s.router.Group(func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(s.auth.Middleware(next.ServeHTTP)) }) r.Get("/jobs", s.handleUIJobsPage) r.Get("/jobs/new", s.handleUINewJobPage) r.Get("/jobs/{id}", s.handleUIJobDetailPage) r.Get("/ui/fragments/jobs", s.handleUIJobsFragment) r.Get("/ui/fragments/jobs/{id}/tasks", s.handleUIJobTasksFragment) r.Get("/ui/fragments/jobs/{id}/files", s.handleUIJobFilesFragment) }) s.router.Group(func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(s.auth.AdminMiddleware(next.ServeHTTP)) }) r.Get("/admin", s.handleUIAdminPage) r.Get("/ui/fragments/admin/runners", s.handleUIAdminRunnersFragment) r.Get("/ui/fragments/admin/users", s.handleUIAdminUsersFragment) r.Get("/ui/fragments/admin/apikeys", s.handleUIAdminAPIKeysFragment) }) } func (s *Manager) sessionFromRequest(r *http.Request) (*authpkg.Session, bool) { cookie, err := r.Cookie("session_id") if err != nil { return nil, false } return s.auth.GetSession(cookie.Value) } func (s *Manager) handleUIRoot(w http.ResponseWriter, r *http.Request) { if _, ok := s.sessionFromRequest(r); ok { http.Redirect(w, r, "/jobs", http.StatusFound) return } http.Redirect(w, r, "/login", http.StatusFound) } func (s *Manager) handleUILoginPage(w http.ResponseWriter, r *http.Request) { if _, ok := s.sessionFromRequest(r); ok { http.Redirect(w, r, "/jobs", http.StatusFound) return } s.ui.render(w, pageData{ Title: "Login", CurrentPath: "/login", ContentTemplate: "page_login", PageScript: "/assets/login.js", Data: map[string]interface{}{ "google_enabled": s.auth.IsGoogleOAuthConfigured(), "discord_enabled": s.auth.IsDiscordOAuthConfigured(), "local_enabled": s.auth.IsLocalLoginEnabled(), "error": r.URL.Query().Get("error"), }, }) } func (s *Manager) handleUILogout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_id") if err == nil { s.auth.DeleteSession(cookie.Value) } expired := &http.Cookie{ Name: "session_id", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, } if s.cfg.IsProductionMode() { expired.Secure = true } http.SetCookie(w, expired) http.Redirect(w, r, "/login", http.StatusFound) } func (s *Manager) handleUIJobsPage(w http.ResponseWriter, r *http.Request) { user, _ := s.sessionFromRequest(r) s.ui.render(w, pageData{ Title: "Jobs", CurrentPath: "/jobs", ContentTemplate: "page_jobs", PageScript: "/assets/jobs.js", User: user, }) } func (s *Manager) handleUINewJobPage(w http.ResponseWriter, r *http.Request) { user, _ := s.sessionFromRequest(r) s.ui.render(w, pageData{ Title: "New Job", CurrentPath: "/jobs/new", ContentTemplate: "page_jobs_new", PageScript: "/assets/job_new.js", User: user, }) } func (s *Manager) handleUIJobDetailPage(w http.ResponseWriter, r *http.Request) { userID, err := getUserID(r) if err != nil { http.Redirect(w, r, "/login", http.StatusFound) return } isAdmin := authpkg.IsAdmin(r.Context()) jobID, err := parseID(r, "id") if err != nil { http.NotFound(w, r) return } job, err := s.getUIJob(jobID, userID, isAdmin) if err != nil { http.NotFound(w, r) return } user, _ := s.sessionFromRequest(r) s.ui.render(w, pageData{ Title: fmt.Sprintf("Job %d", jobID), CurrentPath: "/jobs", ContentTemplate: "page_job_show", PageScript: "/assets/job_show.js", User: user, Data: map[string]interface{}{"job": job}, }) } func (s *Manager) handleUIAdminPage(w http.ResponseWriter, r *http.Request) { user, _ := s.sessionFromRequest(r) regEnabled, _ := s.auth.IsRegistrationEnabled() s.ui.render(w, pageData{ Title: "Admin", CurrentPath: "/admin", ContentTemplate: "page_admin", PageScript: "/assets/admin.js", User: user, Data: map[string]interface{}{ "registration_enabled": regEnabled, }, }) } func (s *Manager) handleUIJobsFragment(w http.ResponseWriter, r *http.Request) { userID, err := getUserID(r) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } jobs, err := s.listUIJobSummaries(userID, 50, 0) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } s.ui.renderTemplate(w, "partial_jobs_table", map[string]interface{}{"jobs": jobs}) } func (s *Manager) handleUIJobTasksFragment(w http.ResponseWriter, r *http.Request) { userID, err := getUserID(r) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := authpkg.IsAdmin(r.Context()) jobID, err := parseID(r, "id") if err != nil { http.Error(w, "invalid job id", http.StatusBadRequest) return } if _, err := s.getUIJob(jobID, userID, isAdmin); err != nil { http.Error(w, "job not found", http.StatusNotFound) return } tasks, err := s.listUITasks(jobID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } s.ui.renderTemplate(w, "partial_job_tasks", map[string]interface{}{ "job_id": jobID, "tasks": tasks, }) } func (s *Manager) handleUIJobFilesFragment(w http.ResponseWriter, r *http.Request) { userID, err := getUserID(r) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } isAdmin := authpkg.IsAdmin(r.Context()) jobID, err := parseID(r, "id") if err != nil { http.Error(w, "invalid job id", http.StatusBadRequest) return } if _, err := s.getUIJob(jobID, userID, isAdmin); err != nil { http.Error(w, "job not found", http.StatusNotFound) return } files, err := s.listUIFiles(jobID, 100) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } outputFiles := make([]uiFileSummary, 0, len(files)) adminInputFiles := make([]uiFileSummary, 0) for _, file := range files { if strings.EqualFold(file.FileType, "output") { outputFiles = append(outputFiles, file) continue } if isAdmin { adminInputFiles = append(adminInputFiles, file) } } s.ui.renderTemplate(w, "partial_job_files", map[string]interface{}{ "job_id": jobID, "files": outputFiles, "is_admin": isAdmin, "admin_input_files": adminInputFiles, }) } func (s *Manager) handleUIAdminRunnersFragment(w http.ResponseWriter, r *http.Request) { var rows *sql.Rows err := s.db.With(func(conn *sql.DB) error { var qErr error rows, qErr = conn.Query(`SELECT id, name, hostname, status, last_heartbeat, priority, created_at FROM runners ORDER BY created_at DESC`) return qErr }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() type runner struct { ID int64 Name string Hostname string Status string LastHeartbeat time.Time Priority int CreatedAt time.Time } all := make([]runner, 0) for rows.Next() { var item runner if scanErr := rows.Scan(&item.ID, &item.Name, &item.Hostname, &item.Status, &item.LastHeartbeat, &item.Priority, &item.CreatedAt); scanErr != nil { http.Error(w, scanErr.Error(), http.StatusInternalServerError) return } all = append(all, item) } s.ui.renderTemplate(w, "partial_admin_runners", map[string]interface{}{"runners": all}) } func (s *Manager) handleUIAdminUsersFragment(w http.ResponseWriter, r *http.Request) { currentUserID, _ := getUserID(r) firstUserID, _ := s.auth.GetFirstUserID() var rows *sql.Rows err := s.db.With(func(conn *sql.DB) error { var qErr error rows, qErr = conn.Query(`SELECT id, email, name, oauth_provider, is_admin, created_at FROM users ORDER BY created_at DESC`) return qErr }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() type user struct { ID int64 Email string Name string OAuthProvider string IsAdmin bool IsFirstUser bool CreatedAt time.Time } all := make([]user, 0) for rows.Next() { var item user if scanErr := rows.Scan(&item.ID, &item.Email, &item.Name, &item.OAuthProvider, &item.IsAdmin, &item.CreatedAt); scanErr != nil { http.Error(w, scanErr.Error(), http.StatusInternalServerError) return } item.IsFirstUser = item.ID == firstUserID all = append(all, item) } s.ui.renderTemplate(w, "partial_admin_users", map[string]interface{}{ "users": all, "current_user_id": currentUserID, }) } func (s *Manager) handleUIAdminAPIKeysFragment(w http.ResponseWriter, r *http.Request) { keys, err := s.secrets.ListRunnerAPIKeys() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } type item struct { ID int64 Name string Scope string Key string IsActive bool CreatedAt time.Time } out := make([]item, 0, len(keys)) for _, key := range keys { out = append(out, item{ ID: key.ID, Name: key.Name, Scope: key.Scope, Key: key.Key, IsActive: key.IsActive, CreatedAt: key.CreatedAt, }) } s.ui.renderTemplate(w, "partial_admin_apikeys", map[string]interface{}{"keys": out}) } func (s *Manager) listUIJobSummaries(userID int64, limit int, offset int) ([]uiJobSummary, error) { rows := &sql.Rows{} err := s.db.With(func(conn *sql.DB) error { var qErr error rows, qErr = conn.Query( `SELECT id, name, status, progress, frame_start, frame_end, output_format, created_at FROM jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?`, userID, limit, offset, ) return qErr }) if err != nil { return nil, err } defer rows.Close() out := make([]uiJobSummary, 0) for rows.Next() { var item uiJobSummary var frameStart, frameEnd sql.NullInt64 var outputFormat sql.NullString if scanErr := rows.Scan(&item.ID, &item.Name, &item.Status, &item.Progress, &frameStart, &frameEnd, &outputFormat, &item.CreatedAt); scanErr != nil { return nil, scanErr } if frameStart.Valid { v := int(frameStart.Int64) item.FrameStart = &v } if frameEnd.Valid { v := int(frameEnd.Int64) item.FrameEnd = &v } if outputFormat.Valid { item.OutputFormat = &outputFormat.String } out = append(out, item) } return out, nil } func (s *Manager) getUIJob(jobID int64, userID int64, isAdmin bool) (uiJobSummary, error) { var item uiJobSummary var frameStart, frameEnd sql.NullInt64 var outputFormat sql.NullString err := s.db.With(func(conn *sql.DB) error { if isAdmin { return conn.QueryRow( `SELECT id, name, status, progress, frame_start, frame_end, output_format, created_at FROM jobs WHERE id = ?`, jobID, ).Scan(&item.ID, &item.Name, &item.Status, &item.Progress, &frameStart, &frameEnd, &outputFormat, &item.CreatedAt) } return conn.QueryRow( `SELECT id, name, status, progress, frame_start, frame_end, output_format, created_at FROM jobs WHERE id = ? AND user_id = ?`, jobID, userID, ).Scan(&item.ID, &item.Name, &item.Status, &item.Progress, &frameStart, &frameEnd, &outputFormat, &item.CreatedAt) }) if err != nil { return uiJobSummary{}, err } if frameStart.Valid { v := int(frameStart.Int64) item.FrameStart = &v } if frameEnd.Valid { v := int(frameEnd.Int64) item.FrameEnd = &v } if outputFormat.Valid { item.OutputFormat = &outputFormat.String } return item, nil } func (s *Manager) listUITasks(jobID int64) ([]uiTaskSummary, error) { var rows *sql.Rows err := s.db.With(func(conn *sql.DB) error { var qErr error rows, qErr = conn.Query( `SELECT id, task_type, status, frame, frame_end, current_step, retry_count, error_message, started_at, completed_at FROM tasks WHERE job_id = ? ORDER BY id ASC`, jobID, ) return qErr }) if err != nil { return nil, err } defer rows.Close() out := make([]uiTaskSummary, 0) for rows.Next() { var item uiTaskSummary var frameEnd sql.NullInt64 var currentStep sql.NullString var errMsg sql.NullString var startedAt, completedAt sql.NullTime if scanErr := rows.Scan( &item.ID, &item.TaskType, &item.Status, &item.Frame, &frameEnd, ¤tStep, &item.RetryCount, &errMsg, &startedAt, &completedAt, ); scanErr != nil { return nil, scanErr } if frameEnd.Valid { v := int(frameEnd.Int64) item.FrameEnd = &v } if currentStep.Valid { item.CurrentStep = currentStep.String } if errMsg.Valid { item.Error = errMsg.String } if startedAt.Valid { item.StartedAt = &startedAt.Time } if completedAt.Valid { item.CompletedAt = &completedAt.Time } out = append(out, item) } return out, nil } func (s *Manager) listUIFiles(jobID int64, limit int) ([]uiFileSummary, error) { var rows *sql.Rows err := s.db.With(func(conn *sql.DB) error { var qErr error rows, qErr = conn.Query( `SELECT id, file_name, file_type, file_size, created_at FROM job_files WHERE job_id = ? ORDER BY created_at DESC LIMIT ?`, jobID, limit, ) return qErr }) if err != nil { return nil, err } defer rows.Close() out := make([]uiFileSummary, 0) for rows.Next() { var item uiFileSummary if scanErr := rows.Scan(&item.ID, &item.FileName, &item.FileType, &item.FileSize, &item.CreatedAt); scanErr != nil { return nil, scanErr } out = append(out, item) } return out, nil } func parseBoolForm(r *http.Request, key string) bool { v := strings.TrimSpace(strings.ToLower(r.FormValue(key))) return v == "1" || v == "true" || v == "on" || v == "yes" } func parseIntQuery(r *http.Request, key string, fallback int) int { raw := strings.TrimSpace(r.URL.Query().Get(key)) if raw == "" { return fallback } v, err := strconv.Atoi(raw) if err != nil || v < 0 { return fallback } return v }