Inital Commit

This commit is contained in:
2026-02-22 00:50:51 +00:00
commit 4c24b141fe
28 changed files with 1458 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
#bun
bun.lock
.vscode
results/
*.db

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

40
Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
# Stage 1: Build SvelteKit static frontend
FROM oven/bun:alpine AS frontend-builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile || bun install
COPY . .
RUN bun run build
# Stage 2: Build Golang server
FROM golang:1.23-alpine AS backend-builder
# Enable CGO for SQLite3 compiling
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
COPY server/go.mod ./
# Copy go.sum if it exists
COPY server/go.su[m] ./
RUN go mod download
COPY server/ ./
RUN CGO_ENABLED=1 GOOS=linux go build -o sim-link-server main.go
# Stage 3: Final lightweight runtime image
FROM alpine:latest
WORKDIR /app
# Install sqlite-libs and tzdata for go-sqlite3 runtime
RUN apk add --no-cache sqlite-libs tzdata
# Copy built frontend from Stage 1
COPY --from=frontend-builder /app/build ./build
# Copy built Golang binary to system bin so we can run it from any directory
COPY --from=backend-builder /app/sim-link-server /usr/local/bin/sim-link-server
# Change to a server sub-directory so relative paths like "../build"
# and "../results" still resolve properly just as they did in dev.
WORKDIR /app/server
EXPOSE 8080
CMD ["sim-link-server"]

71
README.md Normal file
View File

@@ -0,0 +1,71 @@
# Sim-Link
Sim-Link is a web application designed to take the results of Gazebo simulations, save them, rank the best-performing simulations, and display the detailed results in a clean, easy-to-understand format.
The project is intentionally styled without colors to mimic a legacy system, prioritizing highly readable, pure-text tables over modern visual flair.
## Tech Stack
- **Frontend:** SvelteKit (built into a completely Static Single Page Application using Bun).
- **Backend:** Golang.
- **Database:** SQLite3.
- **Containerization:** Docker & Docker Compose.
## Running with Docker (Recommended)
The easiest way to run Sim-Link is by using the provided `docker-compose` environment. This encapsulates the build steps for both the SvelteKit frontend and the Go backend.
```bash
# Build and start the container in the background
docker compose up -d --build
```
*Note: The `results/` folder and `server/` folder are native volume mounts in Docker Compose. This ensures your simulation logs update live, and the `sim-link.db` SQLite file persists on your host machine.*
You can then access the interface at: [http://localhost:8080](http://localhost:8080)
## Running Locally Manually
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.
```bash
bun install
bun run build
```
*(This produces a `build/` directory that the Go backend expects).*
### 2. Run the Backend Server
Because the backend uses `go-sqlite3`, it requires CGO to compile. Navigate completely into the `server` directory so the relative paths for serving the `../build` and `../results` folders remain accurate.
```bash
cd server
go run .
```
The server will automatically scan the `../results` folder on startup, index the simulations into the SQLite database, and host the web interface at [http://localhost:8080](http://localhost:8080).
## API Endpoints
The Go backend provides a simple REST API to interact with the simulations:
### `GET /api/simulations`
Returns a JSON array of all indexed simulations, including their ID, name, created date, and configuration string.
### `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.
- **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.
### `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"}`
### `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.
- **Behavior:** The Go service validates the simulation ID exists, dynamically builds the memory pool, opens a stream writer caching the binary blob into the `../results/simulation_X/filename.ext` directory path, and records the resource in the primary SQLite instance.
- **Response:** JSON object `{"status": "success", "filename": "example.png"}`

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: sim-link-server
ports:
- "8080:8080"
volumes:
# Mount results explicitly to view simulation outcomes and process new files.
- ./results:/app/results
# Mount the DB directory to retain data. Because our Docker WORKDIR is /app/server,
# the sim-link.db will be saved in /app/server/sim-link.db, matching this volume mapping.
- ./server:/app/server
restart: unless-stopped
environment:
- PORT=8080

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "sim-link",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.53.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.0",
"svelte": "^5.53.2",
"svelte-check": "^4.4.3",
"tailwindcss": "^4.2.0",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.10"
}
}

265
server/api.go Normal file
View File

@@ -0,0 +1,265 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
func createSimulation(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Methods", "POST")
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
configStr := string(bodyBytes)
if configStr == "" {
http.Error(w, "Config cannot be empty", http.StatusBadRequest)
return
}
// Check if this config already exists
var existingID int
var existingName string
err = db.QueryRow("SELECT id, name FROM simulations WHERE config = ?", configStr).Scan(&existingID, &existingName)
if err == nil {
// Found exact config, return the existing run info
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": existingID,
"name": existingName,
})
return
}
var count int
db.QueryRow("SELECT COUNT(*) FROM simulations").Scan(&count)
newName := fmt.Sprintf("simulation_%d", count+1)
// ensure name doesn't exist
for {
var temp int
err := db.QueryRow("SELECT id FROM simulations WHERE name = ?", newName).Scan(&temp)
if err == sql.ErrNoRows {
break
}
count++
newName = fmt.Sprintf("simulation_%d", count+1)
}
res, err := db.Exec("INSERT INTO simulations(name, config) VALUES(?, ?)", newName, configStr)
if err != nil {
log.Println("Insert simulation error:", err)
http.Error(w, "Failed to create", http.StatusInternalServerError)
return
}
newID, _ := res.LastInsertId()
// Ensure the directory exists
resultsDir := filepath.Join("../results", newName)
os.MkdirAll(resultsDir, 0755)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": newID,
"name": newName,
})
}
func getSimulations(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Methods", "GET")
w.WriteHeader(http.StatusOK)
return
}
rows, err := db.Query("SELECT id, name, config, created_at, search_time, total_time FROM simulations")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var sims []Simulation
for rows.Next() {
var s Simulation
err = rows.Scan(&s.ID, &s.Name, &s.Config, &s.CreatedAt, &s.SearchTime, &s.TotalTime)
if err != nil {
log.Println("Row scan error:", err)
continue
}
sims = append(sims, s)
}
if sims == nil {
sims = []Simulation{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(sims)
}
func getSimulationDetails(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS")
w.WriteHeader(http.StatusOK)
return
}
pathParts := strings.Split(strings.Trim(r.URL.Path[len("/api/simulations/"):], "/"), "/")
idStr := pathParts[0]
if idStr == "" || idStr == "create" {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if r.Method == "PUT" && len(pathParts) > 1 && pathParts[1] == "time" {
updateSimulationTime(w, r, idStr)
return
}
if r.Method == "POST" && len(pathParts) > 1 && pathParts[1] == "upload" {
uploadSimulationResource(w, r, idStr)
return
}
if r.Method != "GET" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var s Simulation
err := db.QueryRow("SELECT id, name, config, created_at, search_time, total_time FROM simulations WHERE id = ?", idStr).Scan(&s.ID, &s.Name, &s.Config, &s.CreatedAt, &s.SearchTime, &s.TotalTime)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Simulation not found", http.StatusNotFound)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
rows, err := db.Query("SELECT filename FROM resources WHERE simulation_id = ?", s.ID)
if err == nil {
defer rows.Close()
for rows.Next() {
var fname string
if err := rows.Scan(&fname); err == nil {
s.Resources = append(s.Resources, fname)
}
}
}
if s.Resources == nil {
s.Resources = []string{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s)
}
func updateSimulationTime(w http.ResponseWriter, r *http.Request, idStr string) {
var reqBody struct {
SearchTime *float64 `json:"search_time"`
TotalTime *float64 `json:"total_time"`
}
err := json.NewDecoder(r.Body).Decode(&reqBody)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
if reqBody.SearchTime == nil || reqBody.TotalTime == nil {
http.Error(w, "Missing search_time or total_time", http.StatusBadRequest)
return
}
_, err = db.Exec("UPDATE simulations SET search_time = ?, total_time = ? WHERE id = ?", *reqBody.SearchTime, *reqBody.TotalTime, idStr)
if err != nil {
log.Println("Update simulation error:", err)
http.Error(w, "Failed to update", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func uploadSimulationResource(w http.ResponseWriter, r *http.Request, idStr string) {
var simName string
err := db.QueryRow("SELECT name FROM simulations WHERE id = ?", idStr).Scan(&simName)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "Simulation not found", http.StatusNotFound)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
err = r.ParseMultipartForm(500 << 20) // 500 MB max memory/file bounds
if err != nil {
http.Error(w, "Error parsing form: "+err.Error(), http.StatusBadRequest)
return
}
file, handler, err := r.FormFile("file")
if err != nil {
http.Error(w, "Error retrieving the file", http.StatusBadRequest)
return
}
defer file.Close()
destPath := filepath.Join("../results", simName, handler.Filename)
dst, err := os.Create(destPath)
if err != nil {
http.Error(w, "Error creating destination file: "+err.Error(), http.StatusInternalServerError)
return
}
defer dst.Close()
_, err = io.Copy(dst, file)
if err != nil {
http.Error(w, "Error saving file: "+err.Error(), http.StatusInternalServerError)
return
}
var resID int
err = db.QueryRow("SELECT id FROM resources WHERE simulation_id = ? AND filename = ?", idStr, handler.Filename).Scan(&resID)
if err == sql.ErrNoRows {
_, err = db.Exec("INSERT INTO resources(simulation_id, filename) VALUES(?, ?)", idStr, handler.Filename)
if err != nil {
log.Println("Error inserting uploaded resource into db:", err)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"filename": handler.Filename,
})
}

118
server/db.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"database/sql"
"log"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
type Simulation struct {
ID int `json:"id"`
Name string `json:"name"`
Resources []string `json:"resources"`
CreatedAt string `json:"created_at"`
Config *string `json:"config"`
SearchTime *float64 `json:"search_time"`
TotalTime *float64 `json:"total_time"`
}
var db *sql.DB
func initDBConnection() {
var err error
db, err = sql.Open("sqlite3", "./sim-link.db")
if err != nil {
log.Fatal(err)
}
createTableSQL := `CREATE TABLE IF NOT EXISTS simulations (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT UNIQUE,
"config" TEXT,
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP
);`
_, err = db.Exec(createTableSQL)
if err != nil {
log.Fatal(err)
}
db.Exec("ALTER TABLE simulations ADD COLUMN config TEXT") // Ignore error if exists
db.Exec("ALTER TABLE simulations ADD COLUMN search_time REAL") // Ignore error if exists
db.Exec("ALTER TABLE simulations ADD COLUMN total_time REAL") // Ignore error if exists
createResourcesTableSQL := `CREATE TABLE IF NOT EXISTS resources (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"simulation_id" INTEGER,
"filename" TEXT,
FOREIGN KEY(simulation_id) REFERENCES simulations(id)
);`
_, err = db.Exec(createResourcesTableSQL)
if err != nil {
log.Fatal(err)
}
}
func syncResults() {
resultsDir := "../results"
if _, err := os.Stat(resultsDir); os.IsNotExist(err) {
log.Println("Results directory does not exist, skipping sync")
return
}
entries, err := os.ReadDir(resultsDir)
if err != nil {
log.Println("Error reading results directory:", err)
return
}
for _, entry := range entries {
if entry.IsDir() {
simName := entry.Name()
simID := insertSimulation(simName)
// Read subfiles
subFiles, err := os.ReadDir(filepath.Join(resultsDir, simName))
if err == nil {
// Clear old resources for this simulation
db.Exec("DELETE FROM resources WHERE simulation_id = ?", simID)
for _, sf := range subFiles {
if !sf.IsDir() {
insertResource(simID, sf.Name())
}
}
}
}
}
log.Println("Database synchronized with results directory")
}
func insertSimulation(name string) int64 {
var id int64
err := db.QueryRow("SELECT id FROM simulations WHERE name = ?", name).Scan(&id)
if err == sql.ErrNoRows {
res, err := db.Exec("INSERT INTO simulations(name) VALUES(?)", name)
if err != nil {
log.Println("Error inserting simulation:", err)
return 0
}
id, err = res.LastInsertId()
if err != nil {
return 0
}
} else if err != nil {
log.Println("Error selecting simulation:", err)
return 0
}
return id
}
func insertResource(simID int64, filename string) {
_, err := db.Exec("INSERT INTO resources(simulation_id, filename) VALUES(?, ?)", simID, filename)
if err != nil {
log.Println("Error inserting resource:", err)
}
}

5
server/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module sim-link-server
go 1.22.2
require github.com/mattn/go-sqlite3 v1.14.34 // indirect

2
server/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

57
server/main.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
_ "github.com/mattn/go-sqlite3"
)
func main() {
initDBConnection()
defer db.Close()
syncResults()
// Handle API routes
http.HandleFunc("/api/simulations", getSimulations)
http.HandleFunc("/api/simulations/create", createSimulation)
http.HandleFunc("/api/simulations/", getSimulationDetails)
// Serve the static files from results directory
resultsDir := "../results"
fsResults := http.FileServer(http.Dir(resultsDir))
http.Handle("/results/", http.StripPrefix("/results/", fsResults))
// Serve the static frontend with SPA fallback
frontendDir := "../build"
fsFrontend := http.FileServer(http.Dir(frontendDir))
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := filepath.Join(frontendDir, r.URL.Path)
_, err := os.Stat(path)
if os.IsNotExist(err) {
http.ServeFile(w, r, filepath.Join(frontendDir, "index.html"))
return
}
fsFrontend.ServeHTTP(w, r)
})
fmt.Println("Server listening on port 5173")
// Wrap the default ServeMux with our logging middleware
loggedMux := loggingMiddleware(http.DefaultServeMux)
log.Fatal(http.ListenAndServe("0.0.0.0:5173", loggedMux))
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
log.Printf("[%s] %s %s", r.RemoteAddr, r.Method, r.URL.Path)
}
next.ServeHTTP(w, r)
})
}

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

76
src/lib/MediaItem.svelte Normal file
View File

@@ -0,0 +1,76 @@
<script lang="ts">
let { simName, resourceName } = $props();
function getResourceUrl(simName: string, resourceName: string) {
return `/results/${simName}/${resourceName}`;
}
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")
);
}
</script>
<div class="media-item">
<div class="media-title">{resourceName}</div>
{#if isImage(resourceName)}
<img src={getResourceUrl(simName, resourceName)} alt={resourceName} />
{:else if isVideo(resourceName)}
<video controls>
<source
src={getResourceUrl(simName, resourceName)}
type="video/webm"
/>
<source
src={getResourceUrl(simName, resourceName)}
type="video/mp4"
/>
<a href={getResourceUrl(simName, resourceName)}>Download Video</a>
</video>
{:else}
<ul>
<li>
<a href={getResourceUrl(simName, resourceName)} target="_blank"
>{resourceName}</a
>
</li>
</ul>
{/if}
</div>
<style>
img,
video {
max-width: 600px;
border: 1px solid #000;
margin-bottom: 15px;
}
.media-item {
margin-bottom: 20px;
}
.media-title {
font-weight: bold;
margin-bottom: 5px;
}
ul {
list-style-type: square;
}
a {
color: #0000ff;
text-decoration: underline;
}
a:visited {
color: #800080;
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/lib/index.ts Normal file
View File

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

49
src/routes/+error.svelte Normal file
View File

@@ -0,0 +1,49 @@
<script lang="ts">
import { page } from "$app/stores";
</script>
<h1>SYSTEM ERROR</h1>
<div class="error-box">
<div class="code">Error Code: {$page.status}</div>
<div class="message">
Description: {$page.error?.message || "An unknown error occurred."}
</div>
<p>
Please contact your system administrator or check the simulation logs.
</p>
</div>
<a href="/">&lt;&lt; Return to Main Menu</a>
<style>
h1 {
color: red;
border-bottom: 2px solid red;
font-size: 24px;
margin-bottom: 10px;
}
.error-box {
border: 1px solid #000;
padding: 15px;
background-color: #f8f8f8;
margin-bottom: 20px;
max-width: 600px;
}
.code {
font-weight: bold;
font-size: 18px;
margin-bottom: 5px;
}
.message {
margin-bottom: 15px;
}
a {
color: #0000ff;
text-decoration: underline;
}
a:visited {
color: #800080;
}
</style>

21
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,21 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<main>
{@render children()}
</main>
<style>
main {
width: 100%;
height: 100%;
margin: 0;
padding: 10px;
}
</style>

3
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1,3 @@
export const ssr = false;
export const prerender = false;
export const trailingSlash = "always";

471
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,471 @@
<script lang="ts">
import { onMount } from "svelte";
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;
}
});
onMount(async () => {
try {
const res = await fetch("/api/simulations");
if (!res.ok) throw new Error("Failed to fetch simulations");
simulations = await res.json();
} catch (e: any) {
error = e.message;
}
});
</script>
<h1>Sim-Link Simulation Results</h1>
{#if error}
<p class="error">Error: {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>
</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>

14
src/routes/layout.css Normal file
View File

@@ -0,0 +1,14 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
body {
font-family: 'JetBrains Mono', 'Courier New', Courier, monospace;
}

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { onMount } from "svelte";
import MediaItem from "$lib/MediaItem.svelte";
let { data } = $props();
let id = $derived(data.id);
let simulation: {
id: number;
name: string;
created_at: string;
resources: string[];
config: string | null;
search_time: number | null;
total_time: number | null;
} | null = $state(null);
let error = $state("");
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();
} catch (e: any) {
error = e.message;
}
});
</script>
<a href="/" class="back-link">&lt;&lt; Back to Index</a>
{#if error}
<p class="error">Error: {error}</p>
{: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>
{#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
</p>
{/if}
{#if simulation.config}
<div class="config-box">
<strong>Configuration:</strong>
<pre>{simulation.config}</pre>
</div>
{/if}
<hr />
<h2>Media & Results</h2>
{#if simulation.resources && simulation.resources.length > 0}
<div class="media-container">
{#each simulation.resources as res}
<MediaItem simName={simulation.name} resourceName={res} />
{/each}
</div>
{:else}
<p>No resources found for this simulation.</p>
{/if}
{/if}
<style>
:global(body) {
font-family: "JetBrains Mono", Courier, monospace;
background-color: #ffffff;
color: #000000;
}
h1 {
font-size: 24px;
}
h2 {
font-size: 20px;
margin-top: 30px;
border-bottom: 1px solid #000;
}
a {
color: #0000ff;
text-decoration: underline;
}
a:visited {
color: #800080;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
}
.error {
color: red;
font-weight: bold;
}
hr {
border: 0;
border-top: 1px solid #000;
margin: 20px 0;
}
.config-box {
margin-top: 20px;
border: 1px solid #000;
padding: 10px;
background-color: #f8f8f8;
max-width: 800px;
}
pre {
margin: 10px 0 0 0;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,5 @@
export function load({ params }) {
return {
id: params.id
};
}

Binary file not shown.

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

16
svelte.config.js Normal file
View File

@@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
};
export default config;

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

5
vite.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });