Update .gitignore to include log files and database journal files. Modify go.mod to update dependencies for go-sqlite3 and cloud.google.com/go/compute/metadata. Enhance Makefile to include logging options for manager and runner commands. Introduce new job token handling in auth package and implement database migration scripts. Refactor manager and runner components to improve job processing and metadata extraction. Add support for video preview in frontend components and enhance WebSocket management for channel subscriptions.
This commit is contained in:
442
internal/manager/admin.go
Normal file
442
internal/manager/admin.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"jiggablend/pkg/types"
|
||||
)
|
||||
|
||||
// handleGenerateRunnerAPIKey generates a new runner API key
|
||||
func (s *Manager) handleGenerateRunnerAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := getUserID(r)
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Scope string `json:"scope,omitempty"` // 'manager' or 'user'
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: expected valid JSON - %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
s.respondError(w, http.StatusBadRequest, "API key name is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Default scope to 'user' if not specified
|
||||
scope := req.Scope
|
||||
if scope == "" {
|
||||
scope = "user"
|
||||
}
|
||||
if scope != "manager" && scope != "user" {
|
||||
s.respondError(w, http.StatusBadRequest, "Scope must be 'manager' or 'user'")
|
||||
return
|
||||
}
|
||||
|
||||
keyInfo, err := s.secrets.GenerateRunnerAPIKey(userID, req.Name, req.Description, scope)
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate API key: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": keyInfo.ID,
|
||||
"key": keyInfo.Key,
|
||||
"name": keyInfo.Name,
|
||||
"description": keyInfo.Description,
|
||||
"is_active": keyInfo.IsActive,
|
||||
"created_at": keyInfo.CreatedAt,
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// handleListRunnerAPIKeys lists all runner API keys
|
||||
func (s *Manager) handleListRunnerAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := s.secrets.ListRunnerAPIKeys()
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list API keys: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response format (hide sensitive hash data)
|
||||
var response []map[string]interface{}
|
||||
for _, key := range keys {
|
||||
item := map[string]interface{}{
|
||||
"id": key.ID,
|
||||
"key_prefix": key.Key, // Only show prefix, not full key
|
||||
"name": key.Name,
|
||||
"is_active": key.IsActive,
|
||||
"created_at": key.CreatedAt,
|
||||
"created_by": key.CreatedBy,
|
||||
}
|
||||
if key.Description != nil {
|
||||
item["description"] = *key.Description
|
||||
}
|
||||
response = append(response, item)
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleRevokeRunnerAPIKey revokes a runner API key
|
||||
func (s *Manager) handleRevokeRunnerAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
keyID, err := parseID(r, "id")
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.secrets.RevokeRunnerAPIKey(keyID); err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to revoke API key: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, map[string]string{"message": "API key revoked"})
|
||||
}
|
||||
|
||||
// handleDeleteRunnerAPIKey deletes a runner API key
|
||||
func (s *Manager) handleDeleteRunnerAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
keyID, err := parseID(r, "id")
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.secrets.DeleteRunnerAPIKey(keyID); err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete API key: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, map[string]string{"message": "API key deleted"})
|
||||
}
|
||||
|
||||
// handleVerifyRunner manually verifies a runner
|
||||
func (s *Manager) handleVerifyRunner(w http.ResponseWriter, r *http.Request) {
|
||||
runnerID, err := parseID(r, "id")
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if runner exists
|
||||
var exists bool
|
||||
err = s.db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM runners WHERE id = ?)", runnerID).Scan(&exists)
|
||||
})
|
||||
if err != nil || !exists {
|
||||
s.respondError(w, http.StatusNotFound, "Runner not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Mark runner as verified
|
||||
err = s.db.With(func(conn *sql.DB) error {
|
||||
_, err := conn.Exec("UPDATE runners SET verified = 1 WHERE id = ?", runnerID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to verify runner: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, map[string]string{"message": "Runner verified"})
|
||||
}
|
||||
|
||||
// handleDeleteRunner removes a runner
|
||||
func (s *Manager) handleDeleteRunner(w http.ResponseWriter, r *http.Request) {
|
||||
runnerID, err := parseID(r, "id")
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if runner exists
|
||||
var exists bool
|
||||
err = s.db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM runners WHERE id = ?)", runnerID).Scan(&exists)
|
||||
})
|
||||
if err != nil || !exists {
|
||||
s.respondError(w, http.StatusNotFound, "Runner not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete runner
|
||||
err = s.db.With(func(conn *sql.DB) error {
|
||||
_, err := conn.Exec("DELETE FROM runners WHERE id = ?", runnerID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete runner: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, map[string]string{"message": "Runner deleted"})
|
||||
}
|
||||
|
||||
// handleListRunnersAdmin lists all runners with admin details
|
||||
func (s *Manager) handleListRunnersAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
var rows *sql.Rows
|
||||
err := s.db.With(func(conn *sql.DB) error {
|
||||
var err error
|
||||
rows, err = conn.Query(
|
||||
`SELECT id, name, hostname, status, last_heartbeat, capabilities,
|
||||
api_key_id, api_key_scope, priority, created_at
|
||||
FROM runners ORDER BY created_at DESC`,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query runners: %v", err))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
runners := []map[string]interface{}{}
|
||||
for rows.Next() {
|
||||
var runner types.Runner
|
||||
var apiKeyID sql.NullInt64
|
||||
var apiKeyScope string
|
||||
|
||||
err := rows.Scan(
|
||||
&runner.ID, &runner.Name, &runner.Hostname,
|
||||
&runner.Status, &runner.LastHeartbeat, &runner.Capabilities,
|
||||
&apiKeyID, &apiKeyScope, &runner.Priority, &runner.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan runner: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// In polling model, database status is the source of truth
|
||||
// Runners update their status when they poll for jobs
|
||||
runners = append(runners, map[string]interface{}{
|
||||
"id": runner.ID,
|
||||
"name": runner.Name,
|
||||
"hostname": runner.Hostname,
|
||||
"status": runner.Status,
|
||||
"last_heartbeat": runner.LastHeartbeat,
|
||||
"capabilities": runner.Capabilities,
|
||||
"api_key_id": apiKeyID.Int64,
|
||||
"api_key_scope": apiKeyScope,
|
||||
"priority": runner.Priority,
|
||||
"created_at": runner.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, runners)
|
||||
}
|
||||
|
||||
// handleListUsers lists all users
|
||||
func (s *Manager) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
// Get first user ID to mark it in the response
|
||||
firstUserID, err := s.auth.GetFirstUserID()
|
||||
if err != nil {
|
||||
// If no users exist, firstUserID will be 0, which is fine
|
||||
firstUserID = 0
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
err = s.db.With(func(conn *sql.DB) error {
|
||||
var err error
|
||||
rows, err = conn.Query(
|
||||
`SELECT id, email, name, oauth_provider, is_admin, created_at
|
||||
FROM users ORDER BY created_at DESC`,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query users: %v", err))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := []map[string]interface{}{}
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
var email, name, oauthProvider string
|
||||
var isAdmin bool
|
||||
var createdAt time.Time
|
||||
|
||||
err := rows.Scan(&userID, &email, &name, &oauthProvider, &isAdmin, &createdAt)
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan user: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Get job count for this user
|
||||
var jobCount int
|
||||
err = s.db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow("SELECT COUNT(*) FROM jobs WHERE user_id = ?", userID).Scan(&jobCount)
|
||||
})
|
||||
if err != nil {
|
||||
jobCount = 0 // Default to 0 if query fails
|
||||
}
|
||||
|
||||
users = append(users, map[string]interface{}{
|
||||
"id": userID,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"oauth_provider": oauthProvider,
|
||||
"is_admin": isAdmin,
|
||||
"created_at": createdAt,
|
||||
"job_count": jobCount,
|
||||
"is_first_user": userID == firstUserID,
|
||||
})
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// handleGetUserJobs gets all jobs for a specific user
|
||||
func (s *Manager) handleGetUserJobs(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := parseID(r, "id")
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
var exists bool
|
||||
err = s.db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE id = ?)", userID).Scan(&exists)
|
||||
})
|
||||
if err != nil || !exists {
|
||||
s.respondError(w, http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
err = s.db.With(func(conn *sql.DB) error {
|
||||
var err error
|
||||
rows, err = conn.Query(
|
||||
`SELECT id, user_id, job_type, name, status, progress, frame_start, frame_end, output_format,
|
||||
blend_metadata, created_at, started_at, completed_at, error_message
|
||||
FROM jobs WHERE user_id = ? ORDER BY created_at DESC`,
|
||||
userID,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query jobs: %v", err))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
jobs := []types.Job{}
|
||||
for rows.Next() {
|
||||
var job types.Job
|
||||
var jobType string
|
||||
var startedAt, completedAt sql.NullTime
|
||||
var blendMetadataJSON sql.NullString
|
||||
var errorMessage sql.NullString
|
||||
var frameStart, frameEnd sql.NullInt64
|
||||
var outputFormat sql.NullString
|
||||
err := rows.Scan(
|
||||
&job.ID, &job.UserID, &jobType, &job.Name, &job.Status, &job.Progress,
|
||||
&frameStart, &frameEnd, &outputFormat,
|
||||
&blendMetadataJSON, &job.CreatedAt, &startedAt, &completedAt, &errorMessage,
|
||||
)
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to scan job: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
job.JobType = types.JobType(jobType)
|
||||
if frameStart.Valid {
|
||||
fs := int(frameStart.Int64)
|
||||
job.FrameStart = &fs
|
||||
}
|
||||
if frameEnd.Valid {
|
||||
fe := int(frameEnd.Int64)
|
||||
job.FrameEnd = &fe
|
||||
}
|
||||
if outputFormat.Valid {
|
||||
job.OutputFormat = &outputFormat.String
|
||||
}
|
||||
if startedAt.Valid {
|
||||
job.StartedAt = &startedAt.Time
|
||||
}
|
||||
if completedAt.Valid {
|
||||
job.CompletedAt = &completedAt.Time
|
||||
}
|
||||
if blendMetadataJSON.Valid && blendMetadataJSON.String != "" {
|
||||
var metadata types.BlendMetadata
|
||||
if err := json.Unmarshal([]byte(blendMetadataJSON.String), &metadata); err == nil {
|
||||
job.BlendMetadata = &metadata
|
||||
}
|
||||
}
|
||||
if errorMessage.Valid {
|
||||
job.ErrorMessage = errorMessage.String
|
||||
}
|
||||
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, jobs)
|
||||
}
|
||||
|
||||
// handleGetRegistrationEnabled gets the registration enabled setting
|
||||
func (s *Manager) handleGetRegistrationEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
enabled, err := s.auth.IsRegistrationEnabled()
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get registration setting: %v", err))
|
||||
return
|
||||
}
|
||||
s.respondJSON(w, http.StatusOK, map[string]bool{"enabled": enabled})
|
||||
}
|
||||
|
||||
// handleSetRegistrationEnabled sets the registration enabled setting
|
||||
func (s *Manager) handleSetRegistrationEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: expected valid JSON - %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.auth.SetRegistrationEnabled(req.Enabled); err != nil {
|
||||
s.respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to set registration setting: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, map[string]bool{"enabled": req.Enabled})
|
||||
}
|
||||
|
||||
// handleSetUserAdminStatus sets a user's admin status (admin only)
|
||||
func (s *Manager) handleSetUserAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
targetUserID, err := parseID(r, "id")
|
||||
if err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: expected valid JSON - %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.auth.SetUserAdminStatus(targetUserID, req.IsAdmin); err != nil {
|
||||
s.respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user_id": targetUserID,
|
||||
"is_admin": req.IsAdmin,
|
||||
"message": "Admin status updated successfully",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user