Simulation Upload Update
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,4 +26,6 @@ vite.config.ts.timestamp-*
|
||||
bun.lock
|
||||
.vscode
|
||||
results/
|
||||
*.db
|
||||
*.db
|
||||
.svelte-kit
|
||||
package-lock.json
|
||||
@@ -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
|
||||
|
||||
17
README.md
17
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": <int>, "name": <string>}`
|
||||
|
||||
### `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.
|
||||
|
||||
@@ -17,3 +17,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=8080
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
|
||||
101
server/api.go
101
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,
|
||||
})
|
||||
}
|
||||
|
||||
15
server/db.go
15
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
46
server/transcoder.go
Normal file
46
server/transcoder.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
78
src/lib/DualVideoViewer.svelte
Normal file
78
src/lib/DualVideoViewer.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
let { simName, video1, video2, getResourceUrl } = $props();
|
||||
</script>
|
||||
|
||||
<div class="dual-viewer">
|
||||
<div class="videos">
|
||||
{#if video1}
|
||||
<div class="video-container">
|
||||
<div class="video-title">
|
||||
{video1}
|
||||
<a
|
||||
href={getResourceUrl(simName, video1)}
|
||||
download
|
||||
class="dl-link">[Download]</a
|
||||
>
|
||||
</div>
|
||||
<video controls src={getResourceUrl(simName, video1)} muted>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
{/if}
|
||||
{#if video2}
|
||||
<div class="video-container">
|
||||
<div class="video-title">
|
||||
{video2}
|
||||
<a
|
||||
href={getResourceUrl(simName, video2)}
|
||||
download
|
||||
class="dl-link">[Download]</a
|
||||
>
|
||||
</div>
|
||||
<video controls src={getResourceUrl(simName, video2)} muted>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dual-viewer {
|
||||
padding: 15px;
|
||||
background-color: #f9f9f9;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
.videos {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.video-container {
|
||||
flex: 1 1 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.video-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.dl-link {
|
||||
font-weight: normal;
|
||||
margin-left: 10px;
|
||||
color: #0000ff;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
}
|
||||
.dl-link:visited {
|
||||
color: #800080;
|
||||
}
|
||||
video {
|
||||
max-width: 100%;
|
||||
border: 1px solid #000;
|
||||
background-color: #000;
|
||||
}
|
||||
</style>
|
||||
109
src/lib/ImageCarousel.svelte
Normal file
109
src/lib/ImageCarousel.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
let { simName, images, getResourceUrl } = $props();
|
||||
|
||||
let currentIndex = $state(0);
|
||||
|
||||
function next() {
|
||||
if (images && images.length > 0) {
|
||||
currentIndex = (currentIndex + 1) % images.length;
|
||||
}
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (images && images.length > 0) {
|
||||
currentIndex = (currentIndex - 1 + images.length) % images.length;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if images && images.length > 0}
|
||||
<div class="carousel-wrapper">
|
||||
<div class="carousel-container">
|
||||
<div class="carousel-title">
|
||||
Images ({currentIndex + 1} of {images.length})
|
||||
</div>
|
||||
<div class="carousel-content">
|
||||
<button class="nav-btn" onclick={prev}><</button>
|
||||
<div class="image-wrapper">
|
||||
<img
|
||||
src={getResourceUrl(simName, images[currentIndex])}
|
||||
alt={images[currentIndex]}
|
||||
/>
|
||||
<div class="caption">
|
||||
{images[currentIndex]}
|
||||
<a
|
||||
href={getResourceUrl(simName, images[currentIndex])}
|
||||
download
|
||||
class="dl-link">[Download]</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<button class="nav-btn" onclick={next}>></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.carousel-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.carousel-container {
|
||||
border: 1px solid #000;
|
||||
padding: 10px;
|
||||
background-color: #f9f9f9;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.carousel-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #000;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.carousel-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
.nav-btn {
|
||||
background-color: #e0e0e0;
|
||||
border: 1px solid #000;
|
||||
padding: 10px 15px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.nav-btn:hover {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
.image-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.image-wrapper img {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
border: 1px solid #000;
|
||||
object-fit: contain;
|
||||
}
|
||||
.caption {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
.dl-link {
|
||||
margin-left: 10px;
|
||||
color: #0000ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dl-link:visited {
|
||||
color: #800080;
|
||||
}
|
||||
</style>
|
||||
90
src/lib/LogViewer.svelte
Normal file
90
src/lib/LogViewer.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let { simName, resourceName } = $props();
|
||||
|
||||
let logContent = $state("");
|
||||
let error = $state("");
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch(`/results/${simName}/${resourceName}`);
|
||||
if (!res.ok) throw new Error("Failed to load log file.");
|
||||
logContent = await res.text();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<details class="log-viewer">
|
||||
<summary>
|
||||
<span>Simulation Logs: {resourceName}</span>
|
||||
<a
|
||||
href={`/results/${simName}/${resourceName}`}
|
||||
download
|
||||
class="dl-link"
|
||||
onclick={(e) => e.stopPropagation()}>[Download]</a
|
||||
>
|
||||
</summary>
|
||||
<div class="log-content">
|
||||
{#if loading}
|
||||
<p>Loading logs...</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else}
|
||||
<pre>{logContent}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.log-viewer {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #000;
|
||||
background-color: #f9f9f9;
|
||||
max-width: 100%;
|
||||
}
|
||||
summary {
|
||||
font-weight: bold;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
background-color: #e0e0e0;
|
||||
border-bottom: 1px solid #000;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dl-link {
|
||||
font-weight: normal;
|
||||
color: #0000ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dl-link:visited {
|
||||
color: #800080;
|
||||
}
|
||||
details:not([open]) summary {
|
||||
border-bottom: none;
|
||||
}
|
||||
.log-content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
background-color: #fff;
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: "JetBrains Mono", Courier, monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
.error {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -23,7 +23,12 @@
|
||||
</script>
|
||||
|
||||
<div class="media-item">
|
||||
<div class="media-title">{resourceName}</div>
|
||||
<div class="media-title">
|
||||
{resourceName}
|
||||
<a href={getResourceUrl(simName, resourceName)} download class="dl-link"
|
||||
>[Download]</a
|
||||
>
|
||||
</div>
|
||||
{#if isImage(resourceName)}
|
||||
<img src={getResourceUrl(simName, resourceName)} alt={resourceName} />
|
||||
{:else if isVideo(resourceName)}
|
||||
@@ -41,8 +46,10 @@
|
||||
{:else}
|
||||
<ul>
|
||||
<li>
|
||||
<a href={getResourceUrl(simName, resourceName)} target="_blank"
|
||||
>{resourceName}</a
|
||||
<a
|
||||
href={getResourceUrl(simName, resourceName)}
|
||||
target="_blank"
|
||||
download>{resourceName}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
148
src/lib/SimulationSidebar.svelte
Normal file
148
src/lib/SimulationSidebar.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import type { SimulationState } from "./simulationState.svelte";
|
||||
|
||||
let { state }: { state: SimulationState } = $props();
|
||||
</script>
|
||||
|
||||
<aside class="sidebar">
|
||||
<details open class="filter-group">
|
||||
<summary>Search & Sort</summary>
|
||||
<div class="filter-content">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search ID..."
|
||||
bind:value={state.searchQuery}
|
||||
class="input-field full-width"
|
||||
/>
|
||||
<select bind:value={state.sortOrder} class="input-field full-width">
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="oldest">Oldest First</option>
|
||||
<option value="fastest">Fastest</option>
|
||||
<option value="slowest">Slowest</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>Algorithm Pattern</summary>
|
||||
<div class="filter-content">
|
||||
<select
|
||||
bind:value={state.filterPattern}
|
||||
class="input-field full-width"
|
||||
>
|
||||
<option value="any">Any</option>
|
||||
<option value="spiral">Spiral</option>
|
||||
<option value="lawnmower">Lawnmower</option>
|
||||
<option value="levy">Levy</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>Date Range</summary>
|
||||
<div class="filter-content col">
|
||||
<label
|
||||
>From <input
|
||||
type="date"
|
||||
bind:value={state.filterDateFrom}
|
||||
class="input-field"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>To <input
|
||||
type="date"
|
||||
bind:value={state.filterDateTo}
|
||||
class="input-field"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>Flight Altitude</summary>
|
||||
<div class="filter-content col">
|
||||
<label
|
||||
>Min (ft) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={state.filterAltMin}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Max (ft) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={state.filterAltMax}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>UAV Speed</summary>
|
||||
<div class="filter-content col">
|
||||
<label
|
||||
>Min (mph) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={state.filterUavMin}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Max (mph) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={state.filterUavMax}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>UGV Speed</summary>
|
||||
<div class="filter-content col">
|
||||
<label
|
||||
>Min (mph) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={state.filterUgvMin}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Max (mph) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={state.filterUgvMax}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button class="reset-btn" onclick={() => state.resetFilters()}>
|
||||
Reset Filters
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.reset-btn {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
background-color: #e0e0e0;
|
||||
border: 1px solid #000;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.reset-btn:hover {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
</style>
|
||||
52
src/lib/SimulationTable.svelte
Normal file
52
src/lib/SimulationTable.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
parseConfig,
|
||||
formatDate,
|
||||
type SimulationState,
|
||||
} from "./simulationState.svelte";
|
||||
|
||||
let { state }: { state: SimulationState } = $props();
|
||||
</script>
|
||||
|
||||
<main class="table-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Sim Name</th>
|
||||
<th>Search Pattern</th>
|
||||
<th>Date Run</th>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each state.paginatedSimulations as sim}
|
||||
<tr>
|
||||
<td>{sim.id}</td>
|
||||
<td>{sim.name}</td>
|
||||
<td style="text-transform: capitalize;"
|
||||
>{parseConfig(sim.config).pattern || "Unknown"}</td
|
||||
>
|
||||
<td>{formatDate(sim.created_at)}</td>
|
||||
<td><a href={`/simulation/${sim.id}`}>View Details</a></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination">
|
||||
<button
|
||||
onclick={() => state.goToPage(state.currentPage - 1)}
|
||||
disabled={state.currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>Page {state.currentPage} of {state.totalPages}</span>
|
||||
<button
|
||||
onclick={() => state.goToPage(state.currentPage + 1)}
|
||||
disabled={state.currentPage === state.totalPages}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
154
src/lib/dashboard.css
Normal file
154
src/lib/dashboard.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
171
src/lib/simulationState.svelte.ts
Normal file
171
src/lib/simulationState.svelte.ts
Normal file
@@ -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<Simulation[]>([]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,46 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
import "./layout.css";
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
<svelte:head>
|
||||
<title>SimLink</title>
|
||||
<meta name="description" content="SimLink" />
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
<div class="content">
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Sim-Link for Gazebo Simulations</p>
|
||||
<p><a href="https://github.com/RDC-GMU">GMU RDC TEAM</a></p>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
min-height: calc(100vh - 90px); /* Leave room for footer */
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,471 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { SimulationState } from "$lib/simulationState.svelte";
|
||||
import SimulationSidebar from "$lib/SimulationSidebar.svelte";
|
||||
import SimulationTable from "$lib/SimulationTable.svelte";
|
||||
import "$lib/dashboard.css";
|
||||
|
||||
let simulations: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
config: string;
|
||||
}> = $state([]);
|
||||
let error = $state("");
|
||||
|
||||
let searchQuery = $state("");
|
||||
let filterDateFrom = $state("");
|
||||
let filterDateTo = $state("");
|
||||
let filterAltMin = $state("");
|
||||
let filterAltMax = $state("");
|
||||
let filterUavMin = $state("");
|
||||
let filterUavMax = $state("");
|
||||
let filterUgvMin = $state("");
|
||||
let filterUgvMax = $state("");
|
||||
let filterPattern = $state("any");
|
||||
|
||||
let sortOrder = $state("newest");
|
||||
let currentPage = $state(1);
|
||||
let itemsPerPage = 10;
|
||||
|
||||
function parseConfig(config: string) {
|
||||
if (!config)
|
||||
return {
|
||||
altitude: null,
|
||||
uavMin: null,
|
||||
uavMax: null,
|
||||
ugvMin: null,
|
||||
ugvMax: null,
|
||||
};
|
||||
let alt = config.match(/altitude:\s*([\d\.]+)/);
|
||||
let uavMin = config.match(/## UAV[\s\S]*?min_mph:\s*([\d\.]+)/);
|
||||
let uavMax = config.match(/## UAV[\s\S]*?max_mph:\s*([\d\.]+)/);
|
||||
let ugvMin = config.match(/## UGV[\s\S]*?min_mph:\s*([\d\.]+)/);
|
||||
let ugvMax = config.match(/## UGV[\s\S]*?max_mph:\s*([\d\.]+)/);
|
||||
let pattern = config.match(/(spiral|lawnmower|levy):/);
|
||||
return {
|
||||
altitude: alt ? parseFloat(alt[1]) : null,
|
||||
uavMin: uavMin ? parseFloat(uavMin[1]) : null,
|
||||
uavMax: uavMax ? parseFloat(uavMax[1]) : null,
|
||||
ugvMin: ugvMin ? parseFloat(ugvMin[1]) : null,
|
||||
ugvMax: ugvMax ? parseFloat(ugvMax[1]) : null,
|
||||
pattern: pattern ? pattern[1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
let filteredSimulations = $derived(
|
||||
simulations.filter((sim) => {
|
||||
let matchText = sim.id.toString().includes(searchQuery);
|
||||
if (!matchText) return false;
|
||||
|
||||
let simDate = new Date(sim.created_at).getTime();
|
||||
if (filterDateFrom && simDate < new Date(filterDateFrom).getTime())
|
||||
return false;
|
||||
if (
|
||||
filterDateTo &&
|
||||
simDate > new Date(filterDateTo).getTime() + 86400000
|
||||
)
|
||||
return false;
|
||||
|
||||
let p = parseConfig(sim.config);
|
||||
if (
|
||||
filterAltMin &&
|
||||
p.altitude !== null &&
|
||||
p.altitude < parseFloat(filterAltMin)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
filterAltMax &&
|
||||
p.altitude !== null &&
|
||||
p.altitude > parseFloat(filterAltMax)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
filterUavMin &&
|
||||
p.uavMin !== null &&
|
||||
p.uavMin < parseFloat(filterUavMin)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
filterUavMax &&
|
||||
p.uavMax !== null &&
|
||||
p.uavMax > parseFloat(filterUavMax)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
filterUgvMin &&
|
||||
p.ugvMin !== null &&
|
||||
p.ugvMin < parseFloat(filterUgvMin)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
filterUgvMax &&
|
||||
p.ugvMax !== null &&
|
||||
p.ugvMax > parseFloat(filterUgvMax)
|
||||
)
|
||||
return false;
|
||||
|
||||
if (filterPattern !== "any" && p.pattern !== filterPattern)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
let sortedSimulations = $derived(
|
||||
[...filteredSimulations].sort((a, b) => {
|
||||
if (sortOrder === "newest") {
|
||||
return (
|
||||
new Date(b.created_at).getTime() -
|
||||
new Date(a.created_at).getTime()
|
||||
);
|
||||
} else if (sortOrder === "oldest") {
|
||||
return (
|
||||
new Date(a.created_at).getTime() -
|
||||
new Date(b.created_at).getTime()
|
||||
);
|
||||
} else if (sortOrder === "fastest" || 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 (sortOrder === "fastest") {
|
||||
return speedB - speedA;
|
||||
} else {
|
||||
return speedA - speedB;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
|
||||
let totalPages = $derived(
|
||||
Math.max(1, Math.ceil(sortedSimulations.length / itemsPerPage)),
|
||||
);
|
||||
|
||||
let paginatedSimulations = $derived(
|
||||
sortedSimulations.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage,
|
||||
),
|
||||
);
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (
|
||||
searchQuery ||
|
||||
sortOrder ||
|
||||
filterDateFrom ||
|
||||
filterDateTo ||
|
||||
filterAltMin ||
|
||||
filterAltMax ||
|
||||
filterUavMin ||
|
||||
filterUavMax ||
|
||||
filterUgvMin ||
|
||||
filterUgvMax ||
|
||||
filterPattern
|
||||
) {
|
||||
currentPage = 1;
|
||||
}
|
||||
});
|
||||
let state = new SimulationState();
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/simulations");
|
||||
if (!res.ok) throw new Error("Failed to fetch simulations");
|
||||
simulations = await res.json();
|
||||
state.simulations = await res.json();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
state.error = e.message;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>Sim-Link Simulation Results</h1>
|
||||
|
||||
{#if error}
|
||||
<p class="error">Error: {error}</p>
|
||||
{#if state.error}
|
||||
<p class="error">Error: {state.error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="page-layout">
|
||||
<aside class="sidebar">
|
||||
<details open class="filter-group">
|
||||
<summary>Search & Sort</summary>
|
||||
<div class="filter-content">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search ID..."
|
||||
bind:value={searchQuery}
|
||||
class="input-field full-width"
|
||||
/>
|
||||
<select bind:value={sortOrder} class="input-field full-width">
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="oldest">Oldest First</option>
|
||||
<option value="fastest">Fastest</option>
|
||||
<option value="slowest">Slowest</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>Algorithm Pattern</summary>
|
||||
<div class="filter-content">
|
||||
<select
|
||||
bind:value={filterPattern}
|
||||
class="input-field full-width"
|
||||
>
|
||||
<option value="any">Any</option>
|
||||
<option value="spiral">Spiral</option>
|
||||
<option value="lawnmower">Lawnmower</option>
|
||||
<option value="levy">Levy</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>Date Range</summary>
|
||||
<div class="filter-content col">
|
||||
<label
|
||||
>From <input
|
||||
type="date"
|
||||
bind:value={filterDateFrom}
|
||||
class="input-field"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>To <input
|
||||
type="date"
|
||||
bind:value={filterDateTo}
|
||||
class="input-field"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>Flight Altitude</summary>
|
||||
<div class="filter-content col">
|
||||
<label
|
||||
>Min (ft) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={filterAltMin}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Max (ft) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={filterAltMax}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>UAV Speed</summary>
|
||||
<div class="filter-content col">
|
||||
<label
|
||||
>Min (mph) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={filterUavMin}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Max (mph) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={filterUavMax}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="filter-group">
|
||||
<summary>UGV Speed</summary>
|
||||
<div class="filter-content col">
|
||||
<label
|
||||
>Min (mph) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={filterUgvMin}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Max (mph) <input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={filterUgvMax}
|
||||
class="input-field small-num"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
|
||||
<main class="table-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Sim Name</th>
|
||||
<th>Date Run</th>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each paginatedSimulations as sim}
|
||||
<tr>
|
||||
<td>{sim.id}</td>
|
||||
<td>{sim.name}</td>
|
||||
<td>{sim.created_at}</td>
|
||||
<td
|
||||
><a href={`/simulation/${sim.id}`}>View Details</a
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination">
|
||||
<button
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>Page {currentPage} of {totalPages}</span>
|
||||
<button
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
<SimulationSidebar {state} />
|
||||
<SimulationTable {state} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
: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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import MediaItem from "$lib/MediaItem.svelte";
|
||||
import LogViewer from "$lib/LogViewer.svelte";
|
||||
import ImageCarousel from "$lib/ImageCarousel.svelte";
|
||||
import DualVideoViewer from "$lib/DualVideoViewer.svelte";
|
||||
import { formatDate } from "$lib/simulationState.svelte";
|
||||
|
||||
let { data } = $props();
|
||||
let id = $derived(data.id);
|
||||
|
||||
let simulation: {
|
||||
type SimulationDetails = {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
@@ -13,18 +17,207 @@
|
||||
config: string | null;
|
||||
search_time: number | null;
|
||||
total_time: number | null;
|
||||
} | null = $state(null);
|
||||
total_size_bytes: number;
|
||||
};
|
||||
|
||||
let simulation = $state<SimulationDetails | null>(null);
|
||||
let error = $state("");
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function flattenJSON(
|
||||
obj: any,
|
||||
prefix = "",
|
||||
): { key: string; value: string }[] {
|
||||
let result: { key: string; value: string }[] = [];
|
||||
if (!obj) return result;
|
||||
for (let key in obj) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
if (Array.isArray(obj[key])) {
|
||||
result.push({
|
||||
key: prefix + key,
|
||||
value: JSON.stringify(obj[key]),
|
||||
});
|
||||
} else {
|
||||
result = result.concat(
|
||||
flattenJSON(obj[key], prefix + key + "."),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
result.push({ key: prefix + key, value: String(obj[key]) });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let parsedConfig: { key: string; value: string }[] | null = $derived.by(
|
||||
() => {
|
||||
if (!simulation?.config) return null;
|
||||
try {
|
||||
let cleanedConfig = simulation.config.replace(
|
||||
/"([^"]+)\.yaml"\s*:/g,
|
||||
'"$1":',
|
||||
);
|
||||
return flattenJSON(JSON.parse(cleanedConfig));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let searchConfig = $derived(
|
||||
parsedConfig?.filter((c) => c.key.startsWith("search.")),
|
||||
);
|
||||
let uavConfig = $derived(
|
||||
parsedConfig?.filter((c) => c.key.startsWith("uav.")),
|
||||
);
|
||||
let ugvConfig = $derived(
|
||||
parsedConfig?.filter((c) => c.key.startsWith("ugv.")),
|
||||
);
|
||||
let otherConfig = $derived(
|
||||
parsedConfig?.filter(
|
||||
(c) =>
|
||||
!c.key.startsWith("search.") &&
|
||||
!c.key.startsWith("uav.") &&
|
||||
!c.key.startsWith("ugv."),
|
||||
),
|
||||
);
|
||||
|
||||
function isImage(fname: string) {
|
||||
return (
|
||||
fname.toLowerCase().endsWith(".png") ||
|
||||
fname.toLowerCase().endsWith(".jpg") ||
|
||||
fname.toLowerCase().endsWith(".jpeg")
|
||||
);
|
||||
}
|
||||
|
||||
function isVideo(fname: string) {
|
||||
return (
|
||||
fname.toLowerCase().endsWith(".mp4") ||
|
||||
fname.toLowerCase().endsWith(".avi") ||
|
||||
fname.toLowerCase().endsWith(".webm")
|
||||
);
|
||||
}
|
||||
|
||||
let flightPathVideo: string | undefined = $derived(
|
||||
simulation && simulation.resources
|
||||
? simulation.resources.find(
|
||||
(res: string) => res.includes("flight_path") && isVideo(res),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
let cameraVideo: string | undefined = $derived(
|
||||
simulation && simulation.resources
|
||||
? simulation.resources.find(
|
||||
(res: string) => res.includes("camera") && isVideo(res),
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
let imagesList = $derived<string[]>(
|
||||
simulation && simulation.resources
|
||||
? simulation.resources.filter(isImage)
|
||||
: [],
|
||||
);
|
||||
let otherResourcesList = $derived<string[]>(
|
||||
simulation && simulation.resources
|
||||
? simulation.resources.filter(
|
||||
(res: string) =>
|
||||
!isImage(res) &&
|
||||
res !== "log.txt" &&
|
||||
res !== flightPathVideo &&
|
||||
res !== cameraVideo,
|
||||
)
|
||||
: [],
|
||||
);
|
||||
|
||||
function getResourceUrl(simName: string, resourceName: string) {
|
||||
return `/results/${simName}/${resourceName}`;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/simulations/${id}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch simulation details");
|
||||
simulation = await res.json();
|
||||
newName = simulation!.name;
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
let isEditing = $state(false);
|
||||
let newName = $state("");
|
||||
let uploadFiles = $state<FileList | null>(null);
|
||||
let saveError = $state("");
|
||||
|
||||
async function handleSave() {
|
||||
if (!simulation) return;
|
||||
saveError = "";
|
||||
|
||||
// 1. Rename logic
|
||||
if (newName.trim() !== simulation.name) {
|
||||
const renameRes = await fetch(`/api/simulations/${id}/rename`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName }),
|
||||
});
|
||||
|
||||
if (!renameRes.ok) {
|
||||
const text = await renameRes.text();
|
||||
saveError = text || "Failed to rename simulation.";
|
||||
return;
|
||||
}
|
||||
|
||||
const renameData = await renameRes.json();
|
||||
simulation.name = renameData.new_name;
|
||||
}
|
||||
|
||||
// 2. Upload Logic
|
||||
if (uploadFiles && uploadFiles.length > 0) {
|
||||
for (let i = 0; i < uploadFiles.length; i++) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", uploadFiles[i]);
|
||||
|
||||
const uploadRes = await fetch(`/api/simulations/${id}/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
saveError = `Failed to upload: ${uploadFiles[i].name}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const upData = await uploadRes.json();
|
||||
if (!simulation.resources.includes(upData.filename)) {
|
||||
simulation.resources = [
|
||||
...simulation.resources,
|
||||
upData.filename,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isEditing = false;
|
||||
uploadFiles = null;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
if (simulation) {
|
||||
newName = simulation.name;
|
||||
}
|
||||
isEditing = false;
|
||||
saveError = "";
|
||||
uploadFiles = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<a href="/" class="back-link"><< Back to Index</a>
|
||||
@@ -34,36 +227,154 @@
|
||||
{:else if !simulation}
|
||||
<p>Loading...</p>
|
||||
{:else}
|
||||
<h1>Simulation Detail: {simulation.name}</h1>
|
||||
<p><strong>ID:</strong> {simulation.id}</p>
|
||||
<p><strong>Date Code:</strong> {simulation.created_at}</p>
|
||||
<div class="header-container">
|
||||
{#if !isEditing}
|
||||
<h1>Simulation Detail: {simulation.name}</h1>
|
||||
<button class="edit-btn" onclick={() => (isEditing = true)}
|
||||
>Edit</button
|
||||
>
|
||||
{:else}
|
||||
<div class="edit-form">
|
||||
<h2>Edit Simulation</h2>
|
||||
<div class="form-group">
|
||||
<label for="sim-name">Name:</label>
|
||||
<input id="sim-name" type="text" bind:value={newName} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sim-files">Upload Media:</label>
|
||||
<input
|
||||
id="sim-files"
|
||||
type="file"
|
||||
multiple
|
||||
bind:files={uploadFiles}
|
||||
/>
|
||||
</div>
|
||||
{#if saveError}
|
||||
<p class="error">{saveError}</p>
|
||||
{/if}
|
||||
<div class="form-actions">
|
||||
<button class="save-btn" onclick={handleSave}>Save</button>
|
||||
<button class="cancel-btn" onclick={cancelEdit}
|
||||
>Cancel</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong>ID:</strong>
|
||||
{simulation.id}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Search Pattern:</strong>
|
||||
<span style="text-transform: capitalize;"
|
||||
>{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"}</span
|
||||
>
|
||||
</p>
|
||||
<p><strong>Date Code:</strong> {formatDate(simulation.created_at)}</p>
|
||||
|
||||
{#if simulation.search_time !== null && simulation.total_time !== null}
|
||||
<p>
|
||||
<strong>Search Time:</strong>
|
||||
{simulation.search_time}s | <strong>Total Time:</strong>
|
||||
{simulation.total_time}s
|
||||
{simulation.search_time.toFixed(2)}s | <strong>Total Time:</strong>
|
||||
{simulation.total_time.toFixed(2)}s
|
||||
</p>
|
||||
{/if}
|
||||
<p>
|
||||
<strong>Total Media Size:</strong>
|
||||
{formatBytes(simulation.total_size_bytes || 0)}
|
||||
</p>
|
||||
|
||||
{#if simulation.config}
|
||||
<div class="config-box">
|
||||
<strong>Configuration:</strong>
|
||||
<pre>{simulation.config}</pre>
|
||||
<strong>Configuration Options:</strong>
|
||||
{#if parsedConfig}
|
||||
{#snippet ConfigTable(
|
||||
title: string,
|
||||
data: { key: string; value: string }[] | undefined,
|
||||
)}
|
||||
{#if data && data.length > 0}
|
||||
<div class="table-wrapper">
|
||||
<h3 class="table-label">{title}</h3>
|
||||
<table class="config-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parameter</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data as item}
|
||||
<tr>
|
||||
<td>{item.key}</td>
|
||||
<td>{item.value}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="tables-container">
|
||||
{@render ConfigTable("Search", searchConfig)}
|
||||
{@render ConfigTable("UGV", ugvConfig)}
|
||||
{@render ConfigTable("UAV", uavConfig)}
|
||||
{@render ConfigTable("Other", otherConfig)}
|
||||
</div>
|
||||
{:else}
|
||||
<pre>{simulation.config}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<hr />
|
||||
<h2>Simulation Logs</h2>
|
||||
{#if simulation.resources && simulation.resources.includes("log.txt")}
|
||||
<LogViewer simName={simulation.name} resourceName="log.txt" />
|
||||
{:else}
|
||||
<p>No log.txt file found for this simulation.</p>
|
||||
{/if}
|
||||
|
||||
{#if flightPathVideo || cameraVideo}
|
||||
<hr />
|
||||
<h2>Flight Path & Camera</h2>
|
||||
<DualVideoViewer
|
||||
simName={simulation.name}
|
||||
video1={flightPathVideo}
|
||||
video2={cameraVideo}
|
||||
{getResourceUrl}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Media & Results</h2>
|
||||
{#if simulation.resources && simulation.resources.length > 0}
|
||||
{#if imagesList.length > 0}
|
||||
<ImageCarousel
|
||||
simName={simulation.name}
|
||||
images={imagesList}
|
||||
{getResourceUrl}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if otherResourcesList.length > 0}
|
||||
<div class="media-container">
|
||||
{#each simulation.resources as res}
|
||||
{#each otherResourcesList as res}
|
||||
<MediaItem simName={simulation.name} resourceName={res} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p>No resources found for this simulation.</p>
|
||||
{/if}
|
||||
|
||||
{#if imagesList.length === 0 && otherResourcesList.length === 0}
|
||||
<p>No other media resources found for this simulation.</p>
|
||||
{/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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user