Inital Commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal 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
71
README.md
Normal 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
19
docker-compose.yml
Normal 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
27
package.json
Normal 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
265
server/api.go
Normal 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
118
server/db.go
Normal 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
5
server/go.mod
Normal 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
2
server/go.sum
Normal 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
57
server/main.go
Normal 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
13
src/app.d.ts
vendored
Normal 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
11
src/app.html
Normal 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
76
src/lib/MediaItem.svelte
Normal 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>
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal 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
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
49
src/routes/+error.svelte
Normal file
49
src/routes/+error.svelte
Normal 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="/"><< 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
21
src/routes/+layout.svelte
Normal 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
3
src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
export const trailingSlash = "always";
|
||||
471
src/routes/+page.svelte
Normal file
471
src/routes/+page.svelte
Normal 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
14
src/routes/layout.css
Normal 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;
|
||||
}
|
||||
115
src/routes/simulation/[id]/+page.svelte
Normal file
115
src/routes/simulation/[id]/+page.svelte
Normal 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"><< 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>
|
||||
5
src/routes/simulation/[id]/+page.ts
Normal file
5
src/routes/simulation/[id]/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function load({ params }) {
|
||||
return {
|
||||
id: params.id
|
||||
};
|
||||
}
|
||||
BIN
static/fonts/JetBrainsMono-SemiBold.ttf
Normal file
BIN
static/fonts/JetBrainsMono-SemiBold.ttf
Normal file
Binary file not shown.
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
16
svelte.config.js
Normal file
16
svelte.config.js
Normal 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
20
tsconfig.json
Normal 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
5
vite.config.ts
Normal 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()] });
|
||||
Reference in New Issue
Block a user