diff --git a/README.md b/README.md index 201a8f9..c567f70 100644 --- a/README.md +++ b/README.md @@ -71,4 +71,14 @@ Renames a specific simulation. Automatically updates the database and physically Uploads a new media or data resource file directly into a completed simulation run. Supports both images (e.g., PNGs) and heavy video files (e.g., AVIs) out of the box with an initial multi-part allocation pool mapping of 500 MB. - **Request Data:** `multipart/form-data` packet using the `file` keyword carrying the binary data blocks. - **Behavior:** The Go service validates the simulation ID exists, dynamically builds the memory pool, opens a stream writer caching the binary blob into the `../results/simulation_X/filename.ext` directory path, and records the resource in the primary SQLite instance. -- **Response:** JSON object `{"status": "success", "filename": "example.png"}` \ No newline at end of file +- **Response:** JSON object `{"status": "success", "filename": "example.png"}` + +### `DELETE /api/simulations/:id` +Permanently deletes a complete simulation from the platform. +- **Behavior:** Deletes the record from the SQLite database along with any bound resource hooks. It subsequently initiates a full removal of the linked `../results/:name` directory natively off the OS to completely recover physical disk space. +- **Response:** JSON object `{"status": "success"}` + +### `DELETE /api/simulations/:id/resources/:filename` +Deletes a specific user-uploaded media asset or log file dynamically out of a simulation without dropping the core simulation entity itself. +- **Behavior:** Detaches the file association string from SQLite and forces an OS-level file drop directly onto the specific asset file stored within the simulation's results directory. +- **Response:** JSON object `{"status": "success"}` \ No newline at end of file diff --git a/server/api.go b/server/api.go deleted file mode 100644 index 19e1a4e..0000000 --- a/server/api.go +++ /dev/null @@ -1,403 +0,0 @@ -package main - -import ( - "database/sql" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "strings" -) - -func createSimulation(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Methods", "POST") - w.WriteHeader(http.StatusOK) - return - } - - if r.Method != "POST" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read request body", http.StatusBadRequest) - return - } - defer r.Body.Close() - configStr := string(bodyBytes) - - if configStr == "" { - http.Error(w, "Config cannot be empty", http.StatusBadRequest) - return - } - - // Pattern validation: must contain a search pattern (spiral, lawnmower, or levy) - hasPattern := false - - // Fast regex check or parse to detect patterns - var jsonParsed map[string]interface{} - err = json.Unmarshal(bodyBytes, &jsonParsed) - if err != nil { - http.Error(w, "Invalid configuration format: must be valid JSON", http.StatusBadRequest) - return - } - - // Valid JSON, traverse and look for pattern - for k, v := range jsonParsed { - if k == "search.yaml" || k == "search" { - if searchMap, ok := v.(map[string]interface{}); ok { - if _, ok := searchMap["spiral"]; ok { hasPattern = true } - if _, ok := searchMap["lawnmower"]; ok { hasPattern = true } - if _, ok := searchMap["levy"]; ok { hasPattern = true } - } - } - } - - if !hasPattern { - http.Error(w, "Simulation configuration must include a search pattern (spiral, lawnmower, or levy)", http.StatusBadRequest) - return - } - - // Check if this config already exists - var existingID int - var existingName string - err = db.QueryRow("SELECT id, name FROM simulations WHERE config = ?", configStr).Scan(&existingID, &existingName) - if err == nil { - // Found exact config, return the existing run info - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": existingID, - "name": existingName, - }) - return - } - - var count int - db.QueryRow("SELECT COUNT(*) FROM simulations").Scan(&count) - newName := fmt.Sprintf("simulation_%d", count+1) - - // ensure name doesn't exist - for { - var temp int - err := db.QueryRow("SELECT id FROM simulations WHERE name = ?", newName).Scan(&temp) - if err == sql.ErrNoRows { - break - } - count++ - newName = fmt.Sprintf("simulation_%d", count+1) - } - - res, err := db.Exec("INSERT INTO simulations(name, config) VALUES(?, ?)", newName, configStr) - if err != nil { - log.Println("Insert simulation error:", err) - http.Error(w, "Failed to create", http.StatusInternalServerError) - return - } - newID, _ := res.LastInsertId() - - // Ensure the directory exists - resultsDir := filepath.Join("../results", newName) - os.MkdirAll(resultsDir, 0755) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": newID, - "name": newName, - }) -} - -func getSimulations(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Methods", "GET") - w.WriteHeader(http.StatusOK) - return - } - - rows, err := db.Query("SELECT id, name, config, created_at, search_time, total_time FROM simulations") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - defer rows.Close() - - var sims []Simulation - for rows.Next() { - var s Simulation - err = rows.Scan(&s.ID, &s.Name, &s.Config, &s.CreatedAt, &s.SearchTime, &s.TotalTime) - if err != nil { - log.Println("Row scan error:", err) - continue - } - sims = append(sims, s) - } - - if sims == nil { - sims = []Simulation{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(sims) -} - -func getSimulationDetails(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - if r.Method == "OPTIONS" { - w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS") - w.WriteHeader(http.StatusOK) - return - } - - pathParts := strings.Split(strings.Trim(r.URL.Path[len("/api/simulations/"):], "/"), "/") - idStr := pathParts[0] - - if idStr == "" || idStr == "create" { - http.Error(w, "Not found", http.StatusNotFound) - return - } - - if r.Method == "PUT" && len(pathParts) > 1 && pathParts[1] == "time" { - updateSimulationTime(w, r, idStr) - return - } - - if r.Method == "POST" && len(pathParts) > 1 && pathParts[1] == "upload" { - uploadSimulationResource(w, r, idStr) - return - } - - if r.Method == "PUT" && len(pathParts) > 1 && pathParts[1] == "rename" { - renameSimulation(w, r, idStr) - return - } - - if r.Method == "DELETE" && len(pathParts) == 1 { - deleteSimulation(w, r, idStr) - return - } - - if r.Method != "GET" { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var s Simulation - err := db.QueryRow("SELECT id, name, config, created_at, search_time, total_time FROM simulations WHERE id = ?", idStr).Scan(&s.ID, &s.Name, &s.Config, &s.CreatedAt, &s.SearchTime, &s.TotalTime) - if err != nil { - if err == sql.ErrNoRows { - http.Error(w, "Simulation not found", http.StatusNotFound) - } else { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return - } - - rows, err := db.Query("SELECT filename FROM resources WHERE simulation_id = ?", s.ID) - if err == nil { - defer rows.Close() - for rows.Next() { - var fname string - if err := rows.Scan(&fname); err == nil { - s.Resources = append(s.Resources, fname) - if stat, err := os.Stat(filepath.Join("../results", s.Name, fname)); err == nil { - s.TotalSizeBytes += stat.Size() - } - } - } - } - if s.Resources == nil { - s.Resources = []string{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(s) -} - -func updateSimulationTime(w http.ResponseWriter, r *http.Request, idStr string) { - var reqBody struct { - SearchTime *float64 `json:"search_time"` - TotalTime *float64 `json:"total_time"` - } - - err := json.NewDecoder(r.Body).Decode(&reqBody) - if err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - defer r.Body.Close() - - if reqBody.SearchTime == nil || reqBody.TotalTime == nil { - http.Error(w, "Missing search_time or total_time", http.StatusBadRequest) - return - } - - _, err = db.Exec("UPDATE simulations SET search_time = ?, total_time = ? WHERE id = ?", *reqBody.SearchTime, *reqBody.TotalTime, idStr) - if err != nil { - log.Println("Update simulation error:", err) - http.Error(w, "Failed to update", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -func deleteSimulation(w http.ResponseWriter, r *http.Request, idStr string) { - var name string - err := db.QueryRow("SELECT name FROM simulations WHERE id = ?", idStr).Scan(&name) - if err != nil { - if err == sql.ErrNoRows { - http.Error(w, "Simulation not found", http.StatusNotFound) - } else { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return - } - - // Attempt dropping resource map hooks first - _, err = db.Exec("DELETE FROM resources WHERE simulation_id = ?", idStr) - if err != nil { - log.Println("Warning: Error deleting resources map from DB:", err) - } - - _, err = db.Exec("DELETE FROM simulations WHERE id = ?", idStr) - if err != nil { - log.Println("Error deleting simulation from DB:", err) - http.Error(w, "Failed to delete from database", http.StatusInternalServerError) - return - } - - // Physically destroy data payload mapped onto it - dirPath := filepath.Join("../results", name) - if _, statErr := os.Stat(dirPath); statErr == nil { - err = os.RemoveAll(dirPath) - if err != nil { - log.Println("Warning: Failed fully deleting physical directory items:", err) - } - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -func renameSimulation(w http.ResponseWriter, r *http.Request, idStr string) { - var reqBody struct { - Name string `json:"name"` - } - - err := json.NewDecoder(r.Body).Decode(&reqBody) - if err != nil || strings.TrimSpace(reqBody.Name) == "" { - http.Error(w, "Invalid request body or missing name", http.StatusBadRequest) - return - } - defer r.Body.Close() - - newName := strings.TrimSpace(reqBody.Name) - - var oldName string - err = db.QueryRow("SELECT name FROM simulations WHERE id = ?", idStr).Scan(&oldName) - if err != nil { - http.Error(w, "Simulation not found", http.StatusNotFound) - return - } - - // Make sure the new name doesn't already exist - var tempID int - err = db.QueryRow("SELECT id FROM simulations WHERE name = ?", newName).Scan(&tempID) - if err == nil { - http.Error(w, "A simulation with that name already exists", http.StatusConflict) - return - } - - // Rename the physical storage folder to maintain link parity - oldDirPath := filepath.Join("../results", oldName) - newDirPath := filepath.Join("../results", newName) - if _, statErr := os.Stat(oldDirPath); statErr == nil { - err = os.Rename(oldDirPath, newDirPath) - if err != nil { - log.Println("Error renaming directory:", err) - http.Error(w, "Failed to rename results directory", http.StatusInternalServerError) - return - } - } else { - // Just ensure target dir exists - os.MkdirAll(newDirPath, 0755) - } - - _, err = db.Exec("UPDATE simulations SET name = ? WHERE id = ?", newName, idStr) - if err != nil { - // Attempt revert if DB dies halfway - os.Rename(newDirPath, oldDirPath) - log.Println("Update simulation name error:", err) - http.Error(w, "Failed to update database", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success", "new_name": newName}) -} - -func uploadSimulationResource(w http.ResponseWriter, r *http.Request, idStr string) { - var simName string - err := db.QueryRow("SELECT name FROM simulations WHERE id = ?", idStr).Scan(&simName) - if err != nil { - if err == sql.ErrNoRows { - http.Error(w, "Simulation not found", http.StatusNotFound) - } else { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - return - } - - err = r.ParseMultipartForm(500 << 20) // 500 MB max memory/file bounds - if err != nil { - http.Error(w, "Error parsing form: "+err.Error(), http.StatusBadRequest) - return - } - - file, handler, err := r.FormFile("file") - if err != nil { - http.Error(w, "Error retrieving the file", http.StatusBadRequest) - return - } - defer file.Close() - - destPath := filepath.Join("../results", simName, handler.Filename) - dst, err := os.Create(destPath) - if err != nil { - http.Error(w, "Error creating destination file: "+err.Error(), http.StatusInternalServerError) - return - } - defer dst.Close() - - _, err = io.Copy(dst, file) - if err != nil { - http.Error(w, "Error saving file: "+err.Error(), http.StatusInternalServerError) - return - } - - // Route through faststart transcoder if it happens to be an .avi - finalFilename := transcodeIfNeeded(filepath.Join("../results", simName), handler.Filename) - - var resID int - err = db.QueryRow("SELECT id FROM resources WHERE simulation_id = ? AND filename = ?", idStr, finalFilename).Scan(&resID) - if err == sql.ErrNoRows { - _, err = db.Exec("INSERT INTO resources(simulation_id, filename) VALUES(?, ?)", idStr, finalFilename) - if err != nil { - log.Println("Error inserting uploaded resource into db:", err) - } - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "status": "success", - "filename": finalFilename, - }) -} diff --git a/server/db.go b/server/db.go index d29f90a..5494ac4 100644 --- a/server/db.go +++ b/server/db.go @@ -6,21 +6,11 @@ import ( "os" "path/filepath" "strings" + "sim-link-server/routes" _ "github.com/mattn/go-sqlite3" ) -type Simulation struct { - ID int `json:"id"` - Name string `json:"name"` - Resources []string `json:"resources"` - CreatedAt string `json:"created_at"` - Config *string `json:"config"` - SearchTime *float64 `json:"search_time"` - TotalTime *float64 `json:"total_time"` - TotalSizeBytes int64 `json:"total_size_bytes"` -} - var db *sql.DB func initDBConnection() { @@ -41,9 +31,9 @@ func initDBConnection() { log.Fatal(err) } - db.Exec("ALTER TABLE simulations ADD COLUMN config TEXT") // Ignore error if exists - db.Exec("ALTER TABLE simulations ADD COLUMN search_time REAL") // Ignore error if exists - db.Exec("ALTER TABLE simulations ADD COLUMN total_time REAL") // Ignore error if exists + db.Exec("ALTER TABLE simulations ADD COLUMN config TEXT") + db.Exec("ALTER TABLE simulations ADD COLUMN search_time REAL") + db.Exec("ALTER TABLE simulations ADD COLUMN total_time REAL") createResourcesTableSQL := `CREATE TABLE IF NOT EXISTS resources ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, @@ -78,17 +68,15 @@ func syncResults() { // Read subfiles subFiles, err := os.ReadDir(filepath.Join(resultsDir, simName)) if err == nil { - // Clear old resources for this simulation db.Exec("DELETE FROM resources WHERE simulation_id = ?", simID) - // Check for already inserted to avoid dupes if both exist seen := make(map[string]bool) for _, sf := range subFiles { if !sf.IsDir() { finalName := sf.Name() if strings.ToLower(filepath.Ext(finalName)) == ".avi" { - finalName = transcodeIfNeeded(filepath.Join(resultsDir, simName), finalName) + finalName = routes.TranscodeIfNeeded(filepath.Join(resultsDir, simName), finalName) } if !seen[finalName] { diff --git a/server/main.go b/server/main.go index 5257754..26f6d16 100644 --- a/server/main.go +++ b/server/main.go @@ -7,27 +7,34 @@ import ( "os" "path/filepath" "strings" + "sim-link-server/routes" _ "github.com/mattn/go-sqlite3" ) +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + log.Printf("[%s] %s %s", r.RemoteAddr, r.Method, r.URL.Path) + } + next.ServeHTTP(w, r) + }) +} + func main() { initDBConnection() defer db.Close() syncResults() - // Handle API routes - http.HandleFunc("/api/simulations", getSimulations) - http.HandleFunc("/api/simulations/create", createSimulation) - http.HandleFunc("/api/simulations/", getSimulationDetails) + rt := routes.NewRouter(db) + rt.Register(http.DefaultServeMux) + - // Serve the static files from results directory resultsDir := "../results" fsResults := http.FileServer(http.Dir(resultsDir)) http.Handle("/results/", http.StripPrefix("/results/", fsResults)) - // Serve the static frontend with SPA fallback frontendDir := "../build" fsFrontend := http.FileServer(http.Dir(frontendDir)) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -40,18 +47,12 @@ func main() { fsFrontend.ServeHTTP(w, r) }) - fmt.Println("Server listening on port 5173") + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } - // Wrap the default ServeMux with our logging middleware + fmt.Println("Server listening on port", port) loggedMux := loggingMiddleware(http.DefaultServeMux) - log.Fatal(http.ListenAndServe("0.0.0.0:5173", loggedMux)) -} - -func loggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/") { - log.Printf("[%s] %s %s", r.RemoteAddr, r.Method, r.URL.Path) - } - next.ServeHTTP(w, r) - }) + log.Fatal(http.ListenAndServe("0.0.0.0:"+port, loggedMux)) } diff --git a/server/routes/models.go b/server/routes/models.go new file mode 100644 index 0000000..c24dae1 --- /dev/null +++ b/server/routes/models.go @@ -0,0 +1,12 @@ +package routes + +type Simulation struct { + ID int `json:"id"` + Name string `json:"name"` + Resources []string `json:"resources"` + CreatedAt string `json:"created_at"` + Config *string `json:"config"` + SearchTime *float64 `json:"search_time"` + TotalTime *float64 `json:"total_time"` + TotalSizeBytes int64 `json:"total_size_bytes"` +} diff --git a/server/routes/resources.go b/server/routes/resources.go new file mode 100644 index 0000000..8c045ac --- /dev/null +++ b/server/routes/resources.go @@ -0,0 +1,103 @@ +package routes + +import ( + "database/sql" + "encoding/json" + "io" + "log" + "net/http" + "os" + "path/filepath" +) + +func (rt *Router) UploadSimulationResource(w http.ResponseWriter, r *http.Request, idStr string) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + var simName string + err := rt.DB.QueryRow("SELECT name FROM simulations WHERE id = ?", idStr).Scan(&simName) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Simulation not found", http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + err = r.ParseMultipartForm(500 << 20) // 500 MB max memory/file bounds + if err != nil { + http.Error(w, "Error parsing form: "+err.Error(), http.StatusBadRequest) + return + } + + file, handler, err := r.FormFile("file") + if err != nil { + http.Error(w, "Error retrieving the file", http.StatusBadRequest) + return + } + defer file.Close() + + destPath := filepath.Join("../results", simName, handler.Filename) + dst, err := os.Create(destPath) + if err != nil { + http.Error(w, "Error creating destination file: "+err.Error(), http.StatusInternalServerError) + return + } + defer dst.Close() + + _, err = io.Copy(dst, file) + if err != nil { + http.Error(w, "Error saving file: "+err.Error(), http.StatusInternalServerError) + return + } + + finalFilename := TranscodeIfNeeded(filepath.Join("../results", simName), handler.Filename) + + var resID int + err = rt.DB.QueryRow("SELECT id FROM resources WHERE simulation_id = ? AND filename = ?", idStr, finalFilename).Scan(&resID) + if err == sql.ErrNoRows { + _, err = rt.DB.Exec("INSERT INTO resources(simulation_id, filename) VALUES(?, ?)", idStr, finalFilename) + if err != nil { + log.Println("Error inserting uploaded resource into db:", err) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "filename": finalFilename, + }) +} + +func (rt *Router) DeleteSimulationResource(w http.ResponseWriter, r *http.Request, idStr string, fileName string) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + var simName string + err := rt.DB.QueryRow("SELECT name FROM simulations WHERE id = ?", idStr).Scan(&simName) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Simulation not found", http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + _, err = rt.DB.Exec("DELETE FROM resources WHERE simulation_id = ? AND filename = ?", idStr, fileName) + if err != nil { + log.Println("Error deleting resource from DB:", err) + http.Error(w, "Failed to delete from database", http.StatusInternalServerError) + return + } + + filePath := filepath.Join("../results", simName, fileName) + if _, statErr := os.Stat(filePath); statErr == nil { + err = os.Remove(filePath) + if err != nil { + log.Println("Warning: Failed deleting physical file:", err) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} diff --git a/server/routes/router.go b/server/routes/router.go new file mode 100644 index 0000000..bfb5770 --- /dev/null +++ b/server/routes/router.go @@ -0,0 +1,93 @@ +package routes + +import ( + "database/sql" + "net/http" + "strings" + "net/url" +) + +type Router struct { + DB *sql.DB +} + +func NewRouter(db *sql.DB) *Router { + return &Router{DB: db} +} + +func (rt *Router) Register(mux *http.ServeMux) { + mux.HandleFunc("/api/simulations", rt.handleSimulationsBase) + mux.HandleFunc("/api/simulations/", rt.handleSimulationsPath) +} + +func (rt *Router) handleSimulationsBase(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + rt.OptionsHandler(w, r) + return + } + if r.Method == "GET" { + rt.GetSimulations(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func (rt *Router) handleSimulationsPath(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + rt.OptionsHandler(w, r) + return + } + + pathParts := strings.Split(strings.Trim(r.URL.Path[len("/api/simulations/"):], "/"), "/") + if len(pathParts) == 0 || pathParts[0] == "" { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + if pathParts[0] == "create" && r.Method == "POST" { + rt.CreateSimulation(w, r) + return + } + + idStr := pathParts[0] + + if len(pathParts) == 1 { + if r.Method == "GET" { + rt.GetSimulationDetails(w, r, idStr) + } else if r.Method == "DELETE" { + rt.DeleteSimulation(w, r, idStr) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + return + } + + if len(pathParts) > 1 { + action := pathParts[1] + if r.Method == "PUT" && action == "time" { + rt.UpdateSimulationTime(w, r, idStr) + return + } + if r.Method == "PUT" && action == "rename" { + rt.RenameSimulation(w, r, idStr) + return + } + if r.Method == "POST" && action == "upload" { + rt.UploadSimulationResource(w, r, idStr) + return + } + if r.Method == "DELETE" && action == "resources" && len(pathParts) > 2 { + fileName, _ := url.PathUnescape(pathParts[2]) + rt.DeleteSimulationResource(w, r, idStr, fileName) + return + } + } + + http.Error(w, "Not found", http.StatusNotFound) +} + +func (rt *Router) OptionsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS") + w.WriteHeader(http.StatusOK) +} diff --git a/server/routes/simulations.go b/server/routes/simulations.go new file mode 100644 index 0000000..2944672 --- /dev/null +++ b/server/routes/simulations.go @@ -0,0 +1,265 @@ +package routes + +import ( + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" +) + +func (rt *Router) CreateSimulation(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + configStr := string(bodyBytes) + + if configStr == "" { + http.Error(w, "Config cannot be empty", http.StatusBadRequest) + return + } + + hasPattern := false + var jsonParsed map[string]interface{} + err = json.Unmarshal(bodyBytes, &jsonParsed) + if err != nil { + http.Error(w, "Invalid configuration format: must be valid JSON", http.StatusBadRequest) + return + } + + for k, v := range jsonParsed { + if k == "search.yaml" || k == "search" { + if searchMap, ok := v.(map[string]interface{}); ok { + if _, ok := searchMap["spiral"]; ok { + hasPattern = true + } + if _, ok := searchMap["lawnmower"]; ok { + hasPattern = true + } + if _, ok := searchMap["levy"]; ok { + hasPattern = true + } + } + } + } + + if !hasPattern { + http.Error(w, "Simulation configuration must include a search pattern (spiral, lawnmower, or levy)", http.StatusBadRequest) + return + } + + var existingID int + var existingName string + err = rt.DB.QueryRow("SELECT id, name FROM simulations WHERE config = ?", configStr).Scan(&existingID, &existingName) + if err == nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": existingID, + "name": existingName, + }) + return + } + + var count int + rt.DB.QueryRow("SELECT COUNT(*) FROM simulations").Scan(&count) + newName := fmt.Sprintf("simulation_%d", count+1) + + for { + var temp int + err := rt.DB.QueryRow("SELECT id FROM simulations WHERE name = ?", newName).Scan(&temp) + if err == sql.ErrNoRows { + break + } + count++ + newName = fmt.Sprintf("simulation_%d", count+1) + } + + res, err := rt.DB.Exec("INSERT INTO simulations(name, config) VALUES(?, ?)", newName, configStr) + if err != nil { + log.Println("Insert simulation error:", err) + http.Error(w, "Failed to create", http.StatusInternalServerError) + return + } + newID, _ := res.LastInsertId() + + resultsDir := filepath.Join("../results", newName) + os.MkdirAll(resultsDir, 0755) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": newID, + "name": newName, + }) +} + +func (rt *Router) GetSimulations(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + rows, err := rt.DB.Query("SELECT id, name, config, created_at, search_time, total_time FROM simulations") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var sims []Simulation + for rows.Next() { + var s Simulation + err = rows.Scan(&s.ID, &s.Name, &s.Config, &s.CreatedAt, &s.SearchTime, &s.TotalTime) + if err != nil { + log.Println("Row scan error:", err) + continue + } + sims = append(sims, s) + } + + if sims == nil { + sims = []Simulation{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(sims) +} + +func (rt *Router) GetSimulationDetails(w http.ResponseWriter, r *http.Request, idStr string) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + var s Simulation + err := rt.DB.QueryRow("SELECT id, name, config, created_at, search_time, total_time FROM simulations WHERE id = ?", idStr).Scan(&s.ID, &s.Name, &s.Config, &s.CreatedAt, &s.SearchTime, &s.TotalTime) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Simulation not found", http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + rows, err := rt.DB.Query("SELECT filename FROM resources WHERE simulation_id = ?", s.ID) + if err == nil { + defer rows.Close() + for rows.Next() { + var fname string + if err := rows.Scan(&fname); err == nil { + s.Resources = append(s.Resources, fname) + if stat, err := os.Stat(filepath.Join("../results", s.Name, fname)); err == nil { + s.TotalSizeBytes += stat.Size() + } + } + } + } + if s.Resources == nil { + s.Resources = []string{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s) +} + +func (rt *Router) RenameSimulation(w http.ResponseWriter, r *http.Request, idStr string) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + var reqBody struct { + Name string `json:"name"` + } + + err := json.NewDecoder(r.Body).Decode(&reqBody) + if err != nil || strings.TrimSpace(reqBody.Name) == "" { + http.Error(w, "Invalid request body or missing name", http.StatusBadRequest) + return + } + defer r.Body.Close() + + newName := strings.TrimSpace(reqBody.Name) + + var oldName string + err = rt.DB.QueryRow("SELECT name FROM simulations WHERE id = ?", idStr).Scan(&oldName) + if err != nil { + http.Error(w, "Simulation not found", http.StatusNotFound) + return + } + + // Make sure the new name doesn't already exist + var tempID int + err = rt.DB.QueryRow("SELECT id FROM simulations WHERE name = ?", newName).Scan(&tempID) + if err == nil { + http.Error(w, "A simulation with that name already exists", http.StatusConflict) + return + } + + // Rename the physical storage folder to maintain link parity + oldDirPath := filepath.Join("../results", oldName) + newDirPath := filepath.Join("../results", newName) + if _, statErr := os.Stat(oldDirPath); statErr == nil { + err = os.Rename(oldDirPath, newDirPath) + if err != nil { + log.Println("Error renaming directory:", err) + http.Error(w, "Failed to rename results directory", http.StatusInternalServerError) + return + } + } else { + // Just ensure target dir exists + os.MkdirAll(newDirPath, 0755) + } + + _, err = rt.DB.Exec("UPDATE simulations SET name = ? WHERE id = ?", newName, idStr) + if err != nil { + // Attempt revert if DB dies halfway + os.Rename(newDirPath, oldDirPath) + log.Println("Update simulation name error:", err) + http.Error(w, "Failed to update database", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success", "new_name": newName}) +} + +func (rt *Router) DeleteSimulation(w http.ResponseWriter, r *http.Request, idStr string) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + var name string + err := rt.DB.QueryRow("SELECT name FROM simulations WHERE id = ?", idStr).Scan(&name) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Simulation not found", http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + // Attempt dropping resource map hooks first + _, err = rt.DB.Exec("DELETE FROM resources WHERE simulation_id = ?", idStr) + if err != nil { + log.Println("Warning: Error deleting resources map from DB:", err) + } + + _, err = rt.DB.Exec("DELETE FROM simulations WHERE id = ?", idStr) + if err != nil { + log.Println("Error deleting simulation from DB:", err) + http.Error(w, "Failed to delete from database", http.StatusInternalServerError) + return + } + + // Physically destroy data payload mapped onto it + dirPath := filepath.Join("../results", name) + if _, statErr := os.Stat(dirPath); statErr == nil { + err = os.RemoveAll(dirPath) + if err != nil { + log.Println("Warning: Failed fully deleting physical directory items:", err) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} diff --git a/server/routes/time.go b/server/routes/time.go new file mode 100644 index 0000000..296edfc --- /dev/null +++ b/server/routes/time.go @@ -0,0 +1,38 @@ +package routes + +import ( + "encoding/json" + "log" + "net/http" +) + +func (rt *Router) UpdateSimulationTime(w http.ResponseWriter, r *http.Request, idStr string) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + var reqBody struct { + SearchTime *float64 `json:"search_time"` + TotalTime *float64 `json:"total_time"` + } + + err := json.NewDecoder(r.Body).Decode(&reqBody) + if err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if reqBody.SearchTime == nil || reqBody.TotalTime == nil { + http.Error(w, "Missing search_time or total_time", http.StatusBadRequest) + return + } + + _, err = rt.DB.Exec("UPDATE simulations SET search_time = ?, total_time = ? WHERE id = ?", *reqBody.SearchTime, *reqBody.TotalTime, idStr) + if err != nil { + log.Println("Update simulation error:", err) + http.Error(w, "Failed to update", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} diff --git a/server/transcoder.go b/server/routes/transcoder.go similarity index 87% rename from server/transcoder.go rename to server/routes/transcoder.go index eced919..33cbace 100644 --- a/server/transcoder.go +++ b/server/routes/transcoder.go @@ -1,4 +1,4 @@ -package main +package routes import ( "log" @@ -8,7 +8,7 @@ import ( "strings" ) -func transcodeIfNeeded(simDir, filename string) string { +func TranscodeIfNeeded(simDir, filename string) string { ext := strings.ToLower(filepath.Ext(filename)) if ext != ".avi" { return filename @@ -19,7 +19,6 @@ func transcodeIfNeeded(simDir, filename string) string { aviPath := filepath.Join(simDir, filename) mp4Path := filepath.Join(simDir, mp4Filename) - // If the file was already transcoded prior, just remove the stale .avi and return .mp4 if _, err := os.Stat(mp4Path); err == nil { os.Remove(aviPath) return mp4Filename @@ -27,7 +26,7 @@ func transcodeIfNeeded(simDir, filename string) string { log.Printf("Attempting GPU Transcoding (h264_nvenc) %s to %s...\n", aviPath, mp4Path) cmdGPU := exec.Command("ffmpeg", "-y", "-i", aviPath, "-c:v", "h264_nvenc", "-pix_fmt", "yuv420p", "-movflags", "+faststart", mp4Path) - + if err := cmdGPU.Run(); err == nil { os.Remove(aviPath) return mp4Filename @@ -35,7 +34,7 @@ func transcodeIfNeeded(simDir, filename string) string { log.Printf("GPU transcoding failed/unavailable, falling back to CPU (libx264) for %s...\n", aviPath) cmdCPU := exec.Command("ffmpeg", "-y", "-i", aviPath, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", mp4Path) - + if err := cmdCPU.Run(); err == nil { os.Remove(aviPath) return mp4Filename diff --git a/server/server b/server/server deleted file mode 100755 index 3ceaa5d..0000000 Binary files a/server/server and /dev/null differ diff --git a/src/lib/DualVideoViewer.svelte b/src/lib/DualVideoViewer.svelte index c648020..d6babf4 100644 --- a/src/lib/DualVideoViewer.svelte +++ b/src/lib/DualVideoViewer.svelte @@ -1,5 +1,6 @@