- Removed Node.js build artifacts from .gitignore and adjusted Makefile to reflect changes in web UI build process, now using server-rendered Go templates instead of React. - Updated README to clarify the new web UI architecture and output formats, emphasizing the removal of the Node.js build step. - Added a command to set the number of frames per render task in manager configuration, enhancing user control over rendering settings. - Improved Gitea workflow by removing unnecessary npm install step, streamlining the CI process.
557 lines
14 KiB
Go
557 lines
14 KiB
Go
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
|
|
}
|