Simulation Upload Update

This commit is contained in:
2026-02-21 22:21:05 -05:00
parent 4c24b141fe
commit 913f45b51b
20 changed files with 1479 additions and 492 deletions

4
.gitignore vendored
View File

@@ -26,4 +26,6 @@ vite.config.ts.timestamp-*
bun.lock
.vscode
results/
*.db
*.db
.svelte-kit
package-lock.json

View File

@@ -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

View File

@@ -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.

View File

@@ -17,3 +17,10 @@ services:
restart: unless-stopped
environment:
- PORT=8080
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]

View File

@@ -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,
})
}

View File

@@ -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
}
}
}
}

View File

@@ -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
View 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
}
}

View 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>

View 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}>&lt;</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}>&gt;</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
View 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>

View File

@@ -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;
}

View 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>

View 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
View 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;
}
}

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View 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;
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">&lt;&lt; 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>