From 913f45b51b0b013af532da65195cbe4a30f4aa2e Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sat, 21 Feb 2026 22:21:05 -0500 Subject: [PATCH] Simulation Upload Update --- .gitignore | 4 +- Dockerfile | 4 +- README.md | 17 +- docker-compose.yml | 7 + server/api.go | 101 +++++- server/db.go | 15 +- server/go.mod | 2 +- server/transcoder.go | 46 +++ src/lib/DualVideoViewer.svelte | 78 ++++ src/lib/ImageCarousel.svelte | 109 ++++++ src/lib/LogViewer.svelte | 90 +++++ src/lib/MediaItem.svelte | 18 +- src/lib/SimulationSidebar.svelte | 148 ++++++++ src/lib/SimulationTable.svelte | 52 +++ src/lib/dashboard.css | 154 ++++++++ src/lib/index.ts | 1 - src/lib/simulationState.svelte.ts | 171 +++++++++ src/routes/+layout.svelte | 41 ++- src/routes/+page.svelte | 463 +----------------------- src/routes/simulation/[id]/+page.svelte | 450 ++++++++++++++++++++++- 20 files changed, 1479 insertions(+), 492 deletions(-) create mode 100644 server/transcoder.go create mode 100644 src/lib/DualVideoViewer.svelte create mode 100644 src/lib/ImageCarousel.svelte create mode 100644 src/lib/LogViewer.svelte create mode 100644 src/lib/SimulationSidebar.svelte create mode 100644 src/lib/SimulationTable.svelte create mode 100644 src/lib/dashboard.css delete mode 100644 src/lib/index.ts create mode 100644 src/lib/simulationState.svelte.ts diff --git a/.gitignore b/.gitignore index 0af8ece..6328c55 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ vite.config.ts.timestamp-* bun.lock .vscode results/ -*.db \ No newline at end of file +*.db +.svelte-kit +package-lock.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8aabb43..2fb5cd0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,8 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o sim-link-server main.go FROM alpine:latest WORKDIR /app -# Install sqlite-libs and tzdata for go-sqlite3 runtime -RUN apk add --no-cache sqlite-libs tzdata +# Install sqlite-libs, tzdata for go-sqlite3 runtime, and ffmpeg for faststart .avi/.mp4 transcoding +RUN apk add --no-cache sqlite-libs tzdata ffmpeg # Copy built frontend from Stage 1 COPY --from=frontend-builder /app/build ./build diff --git a/README.md b/README.md index 9f44379..e02155f 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ You can then access the interface at: [http://localhost:8080](http://localhost:8 If you prefer to run the system directly on your OS without Docker, you will need `Bun` and `Go` (with CGO enabled) installed. ### 1. Build the Frontend -Navigate to the root directory and use Bun to render the static HTML/JS payload. +Navigate to the root directory and use `npm` to install dependencies and build the static HTML/JS payload. ```bash -bun install -bun run build +npm install +npm run build ``` *(This produces a `build/` directory that the Go backend expects).* @@ -52,18 +52,23 @@ Returns a JSON array of all indexed simulations, including their ID, name, creat ### `POST /api/simulations/create` Creates a new simulation entry. -- **Request Body:** Raw text containing the YAML configuration block. -- **Behavior:** Checks if an identical config exists. If so, it returns the existing simulation ID/Name for overwriting. If not, it allocates a new `simulation_X` incrementally, inserts it into the database, builds the file directory in `../results`, and returns the new ID/Name. +- **Request Body:** JSON object containing the combined configurations (e.g., `search`, `uav`, `ugv`). The configuration *must* contain at least one search pattern definition (either `spiral`, `lawnmower`, or `levy`) inside the JSON. +- **Behavior:** Checks if an identical config exists. If so, it returns the existing simulation ID/Name for overwriting. If not, it allocates a new `simulation_X` incrementally, inserts it into the database, builds the file directory in `../results`, and returns the new ID/Name. Returns `400 Bad Request` if payload is not valid JSON or lacks a known search pattern. - **Response:** JSON object `{"id": , "name": }` ### `GET /api/simulations/:id` -Provides detailed JSON metadata for a specific simulation ID, including its name, raw text config, creation timestamp, and a dynamically fetched array of all the internal resources (images, logs, videos) located within its folder. +Provides detailed JSON metadata for a specific simulation ID, including its name, JSON config string, creation timestamp, and a dynamically fetched array of all the internal resources (images, logs, videos) located within its folder. ### `PUT /api/simulations/:id/time` Updates the numeric benchmarking metrics (`search_time` and `total_time`) for a specific simulation run. - **Request Body:** JSON object containing floats: `{"search_time": 25.4, "total_time": 105.8}` - **Response:** JSON object `{"status": "success"}` +### `PUT /api/simulations/:id/rename` +Renames a specific simulation. Automatically updates the database and physically renames the matching directory path on the system filesystem to mirror structural links. +- **Request Body:** JSON object containing the new underlying name: `{"name": "new_simulation_name"}` +- **Response:** JSON object `{"status": "success", "new_name": "new_simulation_name"}` + ### `POST /api/simulations/:id/upload` 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. diff --git a/docker-compose.yml b/docker-compose.yml index decc1e2..bc7dc39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,3 +17,10 @@ services: restart: unless-stopped environment: - PORT=8080 + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] diff --git a/server/api.go b/server/api.go index e905d81..96d2d4c 100644 --- a/server/api.go +++ b/server/api.go @@ -38,6 +38,33 @@ func createSimulation(w http.ResponseWriter, r *http.Request) { 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 @@ -146,6 +173,11 @@ func getSimulationDetails(w http.ResponseWriter, r *http.Request) { return } + if r.Method == "PUT" && len(pathParts) > 1 && pathParts[1] == "rename" { + renameSimulation(w, r, idStr) + return + } + if r.Method != "GET" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -169,6 +201,9 @@ func getSimulationDetails(w http.ResponseWriter, r *http.Request) { 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() + } } } } @@ -209,6 +244,63 @@ func updateSimulationTime(w http.ResponseWriter, r *http.Request, idStr string) 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) @@ -248,10 +340,13 @@ func uploadSimulationResource(w http.ResponseWriter, r *http.Request, idStr stri 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, handler.Filename).Scan(&resID) + 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, handler.Filename) + _, err = db.Exec("INSERT INTO resources(simulation_id, filename) VALUES(?, ?)", idStr, finalFilename) if err != nil { log.Println("Error inserting uploaded resource into db:", err) } @@ -260,6 +355,6 @@ func uploadSimulationResource(w http.ResponseWriter, r *http.Request, idStr stri w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "status": "success", - "filename": handler.Filename, + "filename": finalFilename, }) } diff --git a/server/db.go b/server/db.go index 1ee422b..d29f90a 100644 --- a/server/db.go +++ b/server/db.go @@ -5,6 +5,7 @@ import ( "log" "os" "path/filepath" + "strings" _ "github.com/mattn/go-sqlite3" ) @@ -17,6 +18,7 @@ type Simulation struct { Config *string `json:"config"` SearchTime *float64 `json:"search_time"` TotalTime *float64 `json:"total_time"` + TotalSizeBytes int64 `json:"total_size_bytes"` } var db *sql.DB @@ -79,9 +81,20 @@ func syncResults() { // 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() { - insertResource(simID, sf.Name()) + finalName := sf.Name() + if strings.ToLower(filepath.Ext(finalName)) == ".avi" { + finalName = transcodeIfNeeded(filepath.Join(resultsDir, simName), finalName) + } + + if !seen[finalName] { + insertResource(simID, finalName) + seen[finalName] = true + } } } } diff --git a/server/go.mod b/server/go.mod index cc6b3b2..55fe344 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,5 +1,5 @@ module sim-link-server -go 1.22.2 +go 1.23 require github.com/mattn/go-sqlite3 v1.14.34 // indirect diff --git a/server/transcoder.go b/server/transcoder.go new file mode 100644 index 0000000..eced919 --- /dev/null +++ b/server/transcoder.go @@ -0,0 +1,46 @@ +package main + +import ( + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func transcodeIfNeeded(simDir, filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if ext != ".avi" { + return filename + } + + baseName := strings.TrimSuffix(filename, filepath.Ext(filename)) + mp4Filename := baseName + ".mp4" + 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 + } + + 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 + } + + 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 + } else { + log.Println("Failed to transcode (both GPU and CPU):", err) + return filename + } +} diff --git a/src/lib/DualVideoViewer.svelte b/src/lib/DualVideoViewer.svelte new file mode 100644 index 0000000..c648020 --- /dev/null +++ b/src/lib/DualVideoViewer.svelte @@ -0,0 +1,78 @@ + + +
+
+ {#if video1} +
+
+ {video1} + [Download] +
+ +
+ {/if} + {#if video2} +
+
+ {video2} + [Download] +
+ +
+ {/if} +
+
+ + diff --git a/src/lib/ImageCarousel.svelte b/src/lib/ImageCarousel.svelte new file mode 100644 index 0000000..12e8da0 --- /dev/null +++ b/src/lib/ImageCarousel.svelte @@ -0,0 +1,109 @@ + + +{#if images && images.length > 0} + +{/if} + + diff --git a/src/lib/LogViewer.svelte b/src/lib/LogViewer.svelte new file mode 100644 index 0000000..9327aa6 --- /dev/null +++ b/src/lib/LogViewer.svelte @@ -0,0 +1,90 @@ + + +
+ + Simulation Logs: {resourceName} + e.stopPropagation()}>[Download] + +
+ {#if loading} +

Loading logs...

+ {:else if error} +

{error}

+ {:else} +
{logContent}
+ {/if} +
+
+ + diff --git a/src/lib/MediaItem.svelte b/src/lib/MediaItem.svelte index 41acb6b..fe43d93 100644 --- a/src/lib/MediaItem.svelte +++ b/src/lib/MediaItem.svelte @@ -23,7 +23,12 @@
-
{resourceName}
+
+ {resourceName} + [Download] +
{#if isImage(resourceName)} {resourceName} {:else if isVideo(resourceName)} @@ -41,8 +46,10 @@ {:else} @@ -63,6 +70,11 @@ font-weight: bold; margin-bottom: 5px; } + .dl-link { + font-weight: normal; + margin-left: 10px; + font-size: 14px; + } ul { list-style-type: square; } diff --git a/src/lib/SimulationSidebar.svelte b/src/lib/SimulationSidebar.svelte new file mode 100644 index 0000000..053570a --- /dev/null +++ b/src/lib/SimulationSidebar.svelte @@ -0,0 +1,148 @@ + + + + + diff --git a/src/lib/SimulationTable.svelte b/src/lib/SimulationTable.svelte new file mode 100644 index 0000000..97119ae --- /dev/null +++ b/src/lib/SimulationTable.svelte @@ -0,0 +1,52 @@ + + +
+ + + + + + + + + + + + {#each state.paginatedSimulations as sim} + + + + + + + + {/each} + +
IDSim NameSearch PatternDate RunLink
{sim.id}{sim.name}{parseConfig(sim.config).pattern || "Unknown"}{formatDate(sim.created_at)}View Details
+ + +
diff --git a/src/lib/dashboard.css b/src/lib/dashboard.css new file mode 100644 index 0000000..45bb842 --- /dev/null +++ b/src/lib/dashboard.css @@ -0,0 +1,154 @@ +:global(body) { + font-family: "JetBrains Mono", Courier, monospace; + background-color: #ffffff; + color: #000000; +} + +.page-layout { + display: flex; + gap: 30px; + align-items: flex-start; +} + +.sidebar { + width: 300px; + flex-shrink: 0; +} + +.table-content { + flex-grow: 1; + overflow-x: auto; +} + +table { + border-collapse: collapse; + width: 100%; + margin-bottom: 20px; +} + +th, +td { + border: 1px solid #000000; + padding: 12px; + text-align: left; +} + +th { + background-color: #e0e0e0; +} + +tbody tr:nth-child(even) { + background-color: #f9f9f9; +} + +h1 { + font-size: 28px; + margin-bottom: 30px; + padding-bottom: 10px; + border-bottom: 2px solid #000; +} + +a { + color: #0000ff; + text-decoration: underline; +} + +a:visited { + color: #800080; +} + +.error { + color: red; + font-weight: bold; + margin-bottom: 20px; +} + +.filter-group { + border: 1px solid #000; + margin-bottom: 10px; + background-color: #f9f9f9; +} + +summary { + font-weight: bold; + padding: 10px; + cursor: pointer; + background-color: #e0e0e0; + border-bottom: 1px solid #000; +} + +details:not([open]) summary { + border-bottom: none; +} + +.filter-content { + padding: 15px; + display: flex; + flex-direction: column; + gap: 15px; +} + +.filter-content.col { + gap: 10px; +} + +.full-width { + width: 100%; + box-sizing: border-box; +} + +label { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + font-size: 14px; + font-weight: bold; +} + +.input-field { + padding: 5px; + border: 1px solid #000; + font-family: inherit; +} + +.small-num { + width: 70px; +} + +.pagination { + margin-top: 20px; + display: flex; + align-items: center; + gap: 15px; +} + +button { + padding: 5px 10px; + border: 1px solid #000; + background-color: #e0e0e0; + cursor: pointer; + font-family: inherit; +} + +button:disabled { + background-color: #f0f0f0; + color: #888; + cursor: not-allowed; +} + +/* Mobile Responsive Scaling */ +@media (max-width: 768px) { + .page-layout { + flex-direction: column; + } + + .sidebar { + width: 100%; + } + + .table-content { + width: 100%; + overflow-x: auto; + } +} \ No newline at end of file diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/simulationState.svelte.ts b/src/lib/simulationState.svelte.ts new file mode 100644 index 0000000..6130b62 --- /dev/null +++ b/src/lib/simulationState.svelte.ts @@ -0,0 +1,171 @@ +export type Simulation = { + id: number; + name: string; + created_at: string; + config: string; +}; + +export function parseConfig(config: string) { + let def = { + altitude: null, + uavMin: null, + uavMax: null, + ugvMin: null, + ugvMax: null, + pattern: null, + }; + if (!config) return def; + + try { + let cleanedConfig = config.replace(/"([^"]+)\.yaml"\s*:/g, '"$1":'); + let parsed = JSON.parse(cleanedConfig); + let searchObj = parsed["search"] || parsed; + let uavObj = parsed["uav"] || parsed; + let ugvObj = parsed["ugv"] || parsed; + + let pattern = null; + if (searchObj.spiral) pattern = "spiral"; + else if (searchObj.lawnmower) pattern = "lawnmower"; + else if (searchObj.levy) pattern = "levy"; + + return { + altitude: searchObj.altitude != null ? parseFloat(searchObj.altitude) : null, + uavMin: uavObj.speed?.min_mph != null ? parseFloat(uavObj.speed.min_mph) : null, + uavMax: uavObj.speed?.max_mph != null ? parseFloat(uavObj.speed.max_mph) : null, + ugvMin: ugvObj.speed?.min_mph != null ? parseFloat(ugvObj.speed.min_mph) : null, + ugvMax: ugvObj.speed?.max_mph != null ? parseFloat(ugvObj.speed.max_mph) : null, + pattern: pattern, + }; + } catch (e) { + return def; + } +} + +export function formatDate(dateStr: string) { + if (!dateStr) return ""; + return new Date(dateStr).toLocaleString("en-US", { + timeZone: "America/New_York", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short" + }); +} + +export class SimulationState { + simulations = $state([]); + error = $state(""); + + searchQuery = $state(""); + filterDateFrom = $state(""); + filterDateTo = $state(""); + filterAltMin = $state(""); + filterAltMax = $state(""); + filterUavMin = $state(""); + filterUavMax = $state(""); + filterUgvMin = $state(""); + filterUgvMax = $state(""); + filterPattern = $state("any"); + + sortOrder = $state("newest"); + currentPage = $state(1); + itemsPerPage = 10; + + constructor() { + $effect(() => { + // Reset to page 1 whenever filters change + if ( + this.searchQuery !== undefined || + this.sortOrder !== undefined || + this.filterDateFrom !== undefined || + this.filterDateTo !== undefined || + this.filterAltMin !== undefined || + this.filterAltMax !== undefined || + this.filterUavMin !== undefined || + this.filterUavMax !== undefined || + this.filterUgvMin !== undefined || + this.filterUgvMax !== undefined || + this.filterPattern !== undefined + ) { + // Tracking any mutation will trigger this effect + let triggerVar = this.searchQuery + this.sortOrder + this.filterDateFrom + this.filterDateTo + this.filterAltMin + this.filterAltMax + this.filterUavMin + this.filterUavMax + this.filterUgvMin + this.filterUgvMax + this.filterPattern; + this.currentPage = 1; + } + }); + } + + get filteredSimulations() { + return this.simulations.filter((sim) => { + let matchText = sim.id.toString().includes(this.searchQuery); + if (!matchText) return false; + + let simDate = new Date(sim.created_at).getTime(); + if (this.filterDateFrom && simDate < new Date(this.filterDateFrom).getTime()) return false; + // Provide +86400000 ms (1 day) to include the "To" date fully. + if (this.filterDateTo && simDate > new Date(this.filterDateTo).getTime() + 86400000) return false; + + let p = parseConfig(sim.config); + if (this.filterAltMin && p.altitude !== null && p.altitude < parseFloat(this.filterAltMin)) return false; + if (this.filterAltMax && p.altitude !== null && p.altitude > parseFloat(this.filterAltMax)) return false; + if (this.filterUavMin && p.uavMin !== null && p.uavMin < parseFloat(this.filterUavMin)) return false; + if (this.filterUavMax && p.uavMax !== null && p.uavMax > parseFloat(this.filterUavMax)) return false; + if (this.filterUgvMin && p.ugvMin !== null && p.ugvMin < parseFloat(this.filterUgvMin)) return false; + if (this.filterUgvMax && p.ugvMax !== null && p.ugvMax > parseFloat(this.filterUgvMax)) return false; + + if (this.filterPattern !== "any" && p.pattern !== this.filterPattern) return false; + + return true; + }); + } + + get sortedSimulations() { + return [...this.filteredSimulations].sort((a, b) => { + if (this.sortOrder === "newest") return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + else if (this.sortOrder === "oldest") return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); + else if (this.sortOrder === "fastest" || this.sortOrder === "slowest") { + let pA = parseConfig(a.config); + let pB = parseConfig(b.config); + let speedA = (pA.uavMax || 0) + (pA.ugvMax || 0); + let speedB = (pB.uavMax || 0) + (pB.ugvMax || 0); + if (this.sortOrder === "fastest") return speedB - speedA; + else return speedA - speedB; + } + return 0; + }); + } + + get totalPages() { + return Math.max(1, Math.ceil(this.sortedSimulations.length / this.itemsPerPage)); + } + + get paginatedSimulations() { + return this.sortedSimulations.slice( + (this.currentPage - 1) * this.itemsPerPage, + this.currentPage * this.itemsPerPage + ); + } + + resetFilters() { + this.searchQuery = ""; + this.filterDateFrom = ""; + this.filterDateTo = ""; + this.filterAltMin = ""; + this.filterAltMax = ""; + this.filterUavMin = ""; + this.filterUavMax = ""; + this.filterUgvMin = ""; + this.filterUgvMax = ""; + this.filterPattern = "any"; + this.sortOrder = "newest"; + this.currentPage = 1; + } + + goToPage(page: number) { + if (page >= 1 && page <= this.totalPages) { + this.currentPage = page; + } + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3c70bc0..a4ce3cf 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,21 +1,46 @@ - + + SimLink + + -
- {@render children()} -
+
+
+ {@render children()} +
+
+ + \ No newline at end of file + + .app-footer { + text-align: center; + padding: 20px 10px; + border-top: 2px solid #000; + margin-top: 20px; + font-family: "JetBrains Mono", Courier, monospace; + font-size: 14px; + background-color: #f0f0f0; + position: sticky; + bottom: 0; + z-index: 100; + } + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9d53e89..d10fad4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,471 +1,30 @@

Sim-Link Simulation Results

-{#if error} -

Error: {error}

+{#if state.error} +

Error: {state.error}

{/if}
- - -
- - - - - - - - - - - {#each paginatedSimulations as sim} - - - - - - - {/each} - -
IDSim NameDate RunLink
{sim.id}{sim.name}{sim.created_at}View Details
- - -
+ +
- - diff --git a/src/routes/simulation/[id]/+page.svelte b/src/routes/simulation/[id]/+page.svelte index c191bd5..da3d1e9 100644 --- a/src/routes/simulation/[id]/+page.svelte +++ b/src/routes/simulation/[id]/+page.svelte @@ -1,11 +1,15 @@ << Back to Index @@ -34,36 +227,154 @@ {:else if !simulation}

Loading...

{:else} -

Simulation Detail: {simulation.name}

-

ID: {simulation.id}

-

Date Code: {simulation.created_at}

+
+ {#if !isEditing} +

Simulation Detail: {simulation.name}

+ + {:else} +
+

Edit Simulation

+
+ + +
+
+ + +
+ {#if saveError} +

{saveError}

+ {/if} +
+ + +
+
+ {/if} +
+ +

+ ID: + {simulation.id} +

+

+ Search Pattern: + {parsedConfig?.find((c) => c.key === "search.spiral.max_legs") + ? "Spiral" + : parsedConfig?.find((c) => c.key === "search.lawnmower.width") + ? "Lawnmower" + : parsedConfig?.find((c) => c.key === "search.levy.max_steps") + ? "Levy" + : "Unknown"} +

+

Date Code: {formatDate(simulation.created_at)}

{#if simulation.search_time !== null && simulation.total_time !== null}

Search Time: - {simulation.search_time}s | Total Time: - {simulation.total_time}s + {simulation.search_time.toFixed(2)}s | Total Time: + {simulation.total_time.toFixed(2)}s

{/if} +

+ Total Media Size: + {formatBytes(simulation.total_size_bytes || 0)} +

{#if simulation.config}
- Configuration: -
{simulation.config}
+ Configuration Options: + {#if parsedConfig} + {#snippet ConfigTable( + title: string, + data: { key: string; value: string }[] | undefined, + )} + {#if data && data.length > 0} +
+

{title}

+ + + + + + + + + {#each data as item} + + + + + {/each} + +
ParameterValue
{item.key}{item.value}
+
+ {/if} + {/snippet} + +
+ {@render ConfigTable("Search", searchConfig)} + {@render ConfigTable("UGV", ugvConfig)} + {@render ConfigTable("UAV", uavConfig)} + {@render ConfigTable("Other", otherConfig)} +
+ {:else} +
{simulation.config}
+ {/if}
{/if} +
+

Simulation Logs

+ {#if simulation.resources && simulation.resources.includes("log.txt")} + + {:else} +

No log.txt file found for this simulation.

+ {/if} + + {#if flightPathVideo || cameraVideo} +
+

Flight Path & Camera

+ + {/if} +

Media & Results

- {#if simulation.resources && simulation.resources.length > 0} + {#if imagesList.length > 0} + + {/if} + + {#if otherResourcesList.length > 0}
- {#each simulation.resources as res} + {#each otherResourcesList as res} {/each}
- {:else} -

No resources found for this simulation.

+ {/if} + + {#if imagesList.length === 0 && otherResourcesList.length === 0} +

No other media resources found for this simulation.

{/if} {/if} @@ -106,10 +417,121 @@ border: 1px solid #000; padding: 10px; background-color: #f8f8f8; - max-width: 800px; + max-width: 100%; + box-sizing: border-box; + overflow-x: auto; } pre { margin: 10px 0 0 0; white-space: pre-wrap; } + .table-label { + margin: 0 0 5px 0; + font-size: 16px; + text-decoration: underline; + } + .tables-container { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: flex-start; + margin-top: 10px; + } + .table-wrapper { + flex: 1 1 300px; + max-width: 100%; + overflow-x: auto; + } + .config-table { + margin-top: 10px; + width: 100%; + border-collapse: collapse; + background-color: #ffffff; + } + .config-table th, + .config-table td { + border: 1px solid #000; + padding: 8px 10px; + text-align: left; + word-break: break-all; + } + .config-table th { + background-color: #e0e0e0; + } + .header-container { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 20px; + } + .header-container h1 { + margin: 0; + } + .edit-btn, + .save-btn, + .cancel-btn { + padding: 6px 15px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + border: 1px solid #000; + background-color: #e0e0e0; + font-family: inherit; + } + .edit-btn:hover, + .save-btn:hover, + .cancel-btn:hover { + background-color: #d0d0d0; + } + + .edit-form { + border: 1px dashed #000; + padding: 15px; + background-color: #f5f5f5; + width: 100%; + } + .edit-form h2 { + margin-top: 0; + border-bottom: none; + } + .form-group { + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; + } + .form-group label { + font-weight: bold; + min-width: 120px; + } + .form-group input[type="text"] { + flex: 1; + max-width: 400px; + padding: 5px; + font-family: inherit; + border: 1px solid #000; + } + .form-group input[type="file"] { + font-family: inherit; + font-size: 14px; + cursor: pointer; + } + .form-group input[type="file"]::file-selector-button { + padding: 6px 15px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + border: 1px solid #000; + background-color: #e0e0e0; + font-family: inherit; + margin-right: 10px; + } + .form-group input[type="file"]::file-selector-button:hover { + background-color: #d0d0d0; + } + .form-actions { + display: flex; + gap: 10px; + margin-top: 10px; + }