Simulation Details page layout Update

This commit is contained in:
2026-02-23 22:03:52 -05:00
parent 5b2b0cbb1f
commit c0b62300f3
11 changed files with 527 additions and 92 deletions

View File

@@ -87,3 +87,11 @@ Permanently deletes a complete simulation from the platform.
Deletes a specific user-uploaded media asset or log file dynamically out of a simulation without dropping the core simulation entity itself.
- **Behavior:** Detaches the file association string from SQLite and forces an OS-level file drop directly onto the specific asset file stored within the simulation's results directory.
- **Response:** JSON object `{"status": "success"}`
### `DELETE /api/simulations/clear-fails`
Purges all failed tracking simulations globally from the database and physically drops their filesystem result nodes off the host system. A simulation is identified natively as "failed" when its `total_time` tracking variable maps to a `0.0` or numeric `null` value during extraction.
- **Response:** JSON object `{"status": "success", "cleared": <int>}`
### `GET /api/stats`
Retrieves globally aggregated system statistics across the entire database resolving UI constraints.
- **Response:** JSON object `{"total_simulations": <int>, "fastest_sim_id": <int>, "total_storage_bytes": <int>}`

View File

@@ -18,6 +18,17 @@ func NewRouter(db *sql.DB) *Router {
func (rt *Router) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/simulations", rt.handleSimulationsBase)
mux.HandleFunc("/api/simulations/", rt.handleSimulationsPath)
mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
rt.OptionsHandler(w, r)
return
}
if r.Method == "GET" {
rt.GetStats(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
}
func (rt *Router) handleSimulationsBase(w http.ResponseWriter, r *http.Request) {
@@ -49,6 +60,11 @@ func (rt *Router) handleSimulationsPath(w http.ResponseWriter, r *http.Request)
return
}
if pathParts[0] == "clear-fails" && r.Method == "DELETE" {
rt.ClearFailedSimulations(w, r)
return
}
idStr := pathParts[0]
if len(pathParts) == 1 {

View File

@@ -247,3 +247,44 @@ func (rt *Router) DeleteSimulation(w http.ResponseWriter, r *http.Request, idStr
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func (rt *Router) ClearFailedSimulations(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
rows, err := rt.DB.Query(`
SELECT id, name FROM simulations
WHERE search_time IS NULL
AND total_time IS NULL
AND NOT EXISTS (SELECT 1 FROM resources WHERE simulation_id = simulations.id)
`)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var deleteIDs []string
var deleteDirs []string
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err == nil {
deleteIDs = append(deleteIDs, id)
deleteDirs = append(deleteDirs, name)
}
}
rows.Close()
for i, idStr := range deleteIDs {
rt.DB.Exec("DELETE FROM resources WHERE simulation_id = ?", idStr)
rt.DB.Exec("DELETE FROM simulations WHERE id = ?", idStr)
dirPath := filepath.Join("../results", deleteDirs[i])
if _, statErr := os.Stat(dirPath); statErr == nil {
os.RemoveAll(dirPath)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"status": "success", "deleted_count": len(deleteIDs)})
}

43
server/routes/stats.go Normal file
View File

@@ -0,0 +1,43 @@
package routes
import (
"database/sql"
"encoding/json"
"net/http"
"os"
"path/filepath"
)
func (rt *Router) GetStats(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
var totalSims int
err := rt.DB.QueryRow("SELECT COUNT(*) FROM simulations").Scan(&totalSims)
if err != nil {
totalSims = 0
}
var fastestSimID sql.NullInt64
err = rt.DB.QueryRow("SELECT id FROM simulations WHERE total_time IS NOT NULL ORDER BY total_time ASC LIMIT 1").Scan(&fastestSimID)
var totalSize int64 = 0
filepath.Walk("../results", func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
totalSize += info.Size()
}
return nil
})
var fastest *int
if fastestSimID.Valid {
f := int(fastestSimID.Int64)
fastest = &f
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"total_simulations": totalSims,
"fastest_sim_id": fastest,
"total_storage_bytes": totalSize,
})
}

View File

@@ -2,6 +2,33 @@
import type { SimulationState } from "./simulationState.svelte";
let { state }: { state: SimulationState } = $props();
async function clearFails() {
if (
!confirm(
"Are you sure you want to permanently delete all failed simulations?",
)
) {
return;
}
try {
const res = await fetch("/api/simulations/clear-fails", {
method: "DELETE",
});
if (res.ok) {
const data = await res.json();
alert(
`Successfully cleared ${data.deleted_count} failed runs.`,
);
window.location.reload();
} else {
alert("Failed to clear simulations.");
}
} catch (e) {
console.error("Error clearing fails:", e);
alert("Network error clearing fails.");
}
}
</script>
<aside class="sidebar">
@@ -127,10 +154,12 @@
<button class="reset-btn" onclick={() => state.resetFilters()}>
Reset Filters
</button>
<button class="clear-fails-btn" onclick={clearFails}> Clear Fails </button>
</aside>
<style>
.reset-btn {
.reset-btn,
.clear-fails-btn {
width: 100%;
margin-top: 20px;
padding: 10px;
@@ -145,4 +174,13 @@
.reset-btn:hover {
background-color: #d0d0d0;
}
.clear-fails-btn {
margin-top: 10px;
background-color: #ffcccc;
color: #990000;
border-color: #990000;
}
.clear-fails-btn:hover {
background-color: #ff9999;
}
</style>

View File

@@ -4,6 +4,7 @@
formatDate,
type SimulationState,
} from "./simulationState.svelte";
import { formatTime } from "$lib/ts/utils";
let { state }: { state: SimulationState } = $props();
</script>
@@ -15,6 +16,8 @@
<th>ID</th>
<th>Sim Name</th>
<th>Search Pattern</th>
<th>Search Time</th>
<th>Total Time</th>
<th>Date Run</th>
<th>Link</th>
</tr>
@@ -27,6 +30,8 @@
<td style="text-transform: capitalize;"
>{parseConfig(sim.config).pattern || "Unknown"}</td
>
<td>{formatTime(sim.search_time || NaN)}</td>
<td>{formatTime(sim.total_time || NaN)}</td>
<td>{formatDate(sim.created_at)}</td>
<td><a href={`/simulation/${sim.id}`}>View Details</a></td>
</tr>

View File

@@ -3,34 +3,42 @@
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,
.hardware-box {
margin-top: 20px;
@@ -41,23 +49,52 @@ hr {
box-sizing: border-box;
overflow-x: auto;
}
.hardware-box h3 {
margin-top: 0;
margin-bottom: 10px;
text-decoration: underline;
}
.hardware-box p {
margin: 5px 0;
}
pre {
margin: 10px 0 0 0;
white-space: pre-wrap;
}
.super-container {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 15px;
}
.config-category {
padding: 15px;
background: #ffffff;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.config-category h2 {
margin-top: 0;
margin-bottom: 15px;
font-size: 20px;
color: #222;
border-bottom: 2px solid #ccc;
padding-bottom: 5px;
}
.table-label {
margin: 0 0 5px 0;
font-size: 16px;
text-decoration: underline;
}
.tables-container {
display: flex;
flex-wrap: wrap;
@@ -65,17 +102,20 @@ pre {
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;
@@ -83,18 +123,22 @@ pre {
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,
.export-btn,
.save-btn,
@@ -108,22 +152,26 @@ pre {
background-color: #e0e0e0;
font-family: inherit;
}
.edit-btn:hover:not(:disabled),
.export-btn:hover:not(:disabled),
.save-btn:hover:not(:disabled),
.cancel-btn:hover:not(:disabled) {
background-color: #d0d0d0;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-btn {
background-color: #ffcccc;
color: #990000;
border-color: #990000;
margin-left: auto;
}
.delete-btn:hover {
background-color: #ff9999;
}
@@ -134,27 +182,32 @@ button:disabled {
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;
}
.edit-form label {
display: inline-block;
width: 100px;
font-weight: bold;
}
.edit-form input[type="text"] {
flex: 1;
padding: 5px;
border: 1px solid #000;
font-family: inherit;
}
.progress-bar-container {
width: 100%;
background-color: #ddd;
@@ -163,11 +216,13 @@ button:disabled {
position: relative;
height: 25px;
}
.progress-bar {
height: 100%;
background-color: #4caf50;
transition: width 0.2s ease-in-out;
}
.progress-text {
position: absolute;
width: 100%;
@@ -178,11 +233,13 @@ button:disabled {
font-weight: bold;
color: #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;
@@ -193,9 +250,11 @@ button:disabled {
font-family: inherit;
margin-right: 10px;
}
.form-group input[type="file"]::file-selector-button:hover {
background-color: #d0d0d0;
}
.form-actions {
display: flex;
gap: 10px;
@@ -206,17 +265,21 @@ button:disabled {
:global(.no-print) {
display: none !important;
}
:global(body) {
background-color: #fff !important;
color: #000 !important;
padding: 0 !important;
}
:global(*) {
box-shadow: none !important;
}
table {
border: 1px solid #000;
}
th,
td {
border: 1px solid #000;

View File

@@ -3,6 +3,8 @@ export type Simulation = {
name: string;
created_at: string;
config: string;
search_time: number | null;
total_time: number | null;
};
export function parseConfig(config: string) {
@@ -123,12 +125,11 @@ export class SimulationState {
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;
let timeA = a.total_time ?? Infinity;
let timeB = b.total_time ?? Infinity;
if (this.sortOrder === "fastest") return timeA - timeB;
else return timeB - timeA;
}
return 0;
});

View File

@@ -1,6 +1,26 @@
<script lang="ts">
import "./layout.css";
import { onMount } from "svelte";
import { formatBytes } from "$lib/ts/utils";
let { children } = $props();
let stats = $state<{
total_simulations: number;
fastest_sim_id: number | null;
total_storage_bytes: number;
} | null>(null);
onMount(async () => {
try {
const res = await fetch("/api/stats");
if (res.ok) {
stats = await res.json();
}
} catch (e) {
console.error("Failed to fetch footer stats:", e);
}
});
</script>
<svelte:head>
@@ -15,8 +35,24 @@
</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>
<div class="footer-stats">
{#if stats}
<p>Total Storage Used: {formatBytes(stats.total_storage_bytes)}</p>
<p>Total Simulations Run: {stats.total_simulations}</p>
<p>
Fastest Sim: {stats.fastest_sim_id
? `#${stats.fastest_sim_id}`
: "N/A"}
</p>
{:else}
<p>Loading system statistics...</p>
{/if}
</div>
<div class="footer-branding">
<p>Sim-Link for Gazebo Simulations</p>
<p><a href="https://github.com/RDC-GMU">GMU RDC TEAM</a></p>
</div>
<div class="footer-right"></div>
</footer>
<style>
@@ -32,8 +68,10 @@
}
.app-footer {
text-align: center;
padding: 20px 10px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 15px 20px;
border-top: 2px solid #000;
margin-top: 20px;
font-family: "JetBrains Mono", Courier, monospace;
@@ -43,4 +81,21 @@
bottom: 0;
z-index: 100;
}
.footer-stats {
text-align: left;
font-weight: bold;
}
.footer-stats p {
margin: 2px 0;
}
.footer-branding {
text-align: center;
}
.footer-branding p {
margin: 2px 0;
}
</style>

View File

@@ -40,13 +40,19 @@
);
let searchConfig = $derived(
parsedConfig?.filter((c) => c.key.startsWith("search.")),
parsedConfig
?.filter((c) => c.key.startsWith("search."))
.map((c) => ({ ...c, key: c.key.replace("search.", "") })),
);
let uavConfig = $derived(
parsedConfig?.filter((c) => c.key.startsWith("uav.")),
parsedConfig
?.filter((c) => c.key.startsWith("uav."))
.map((c) => ({ ...c, key: c.key.replace("uav.", "") })),
);
let ugvConfig = $derived(
parsedConfig?.filter((c) => c.key.startsWith("ugv.")),
parsedConfig
?.filter((c) => c.key.startsWith("ugv."))
.map((c) => ({ ...c, key: c.key.replace("ugv.", "") })),
);
let otherConfig = $derived(
parsedConfig?.filter(
@@ -57,6 +63,25 @@
),
);
function groupConfig(data: { key: string; value: string }[] | undefined) {
if (!data) return {};
const groups: Record<string, { key: string; value: string }[]> = {
_general: [],
};
for (const item of data) {
const parts = item.key.split(".");
if (parts.length > 1) {
const groupName = parts[0];
const subKey = parts.slice(1).join(".");
if (!groups[groupName]) groups[groupName] = [];
groups[groupName].push({ key: subKey, value: item.value });
} else {
groups["_general"].push(item);
}
}
return groups;
}
let flightPathVideo: string | undefined = $derived(
simulation && simulation.resources
? simulation.resources.find(
@@ -65,10 +90,29 @@
: undefined,
);
let cameraVideo: string | undefined = $derived(
let gazeboVideo: string | undefined = $derived(
simulation && simulation.resources
? simulation.resources.find(
(res: string) => res.includes("camera") && isVideo(res),
(res: string) => res.includes("gazebo") && isVideo(res),
)
: undefined,
);
let uavCamera: string | undefined = $derived(
simulation && simulation.resources
? simulation.resources.find(
(res: string) =>
res.includes("camera") &&
!res.includes("ugv") &&
isVideo(res),
)
: undefined,
);
let ugvCamera: string | undefined = $derived(
simulation && simulation.resources
? simulation.resources.find(
(res: string) => res.includes("ugv_camera") && isVideo(res),
)
: undefined,
);
@@ -85,7 +129,9 @@
!isImage(res) &&
res !== "log.txt" &&
res !== flightPathVideo &&
res !== cameraVideo,
res !== gazeboVideo &&
res !== uavCamera &&
res !== ugvCamera,
)
: [],
);
@@ -419,29 +465,67 @@
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>
{@const groups = groupConfig(data)}
<div class="config-category">
<h2>{title}</h2>
<div class="tables-container">
{#if groups["_general"].length > 0}
<div class="table-wrapper">
<h3 class="table-label">General</h3>
<table class="config-table">
<thead>
<tr
><th>Parameter</th><th
>Value</th
></tr
>
</thead>
<tbody>
{#each groups["_general"] as item}
<tr
><td>{item.key}</td><td
>{item.value}</td
></tr
>
{/each}
</tbody>
</table>
</div>
{/if}
{#each Object.entries(groups).filter(([k]) => k !== "_general") as [groupName, items]}
<div class="table-wrapper">
<h3
class="table-label"
style="text-transform: capitalize;"
>
{groupName}
</h3>
<table class="config-table">
<thead>
<tr
><th>Parameter</th><th
>Value</th
></tr
>
</thead>
<tbody>
{#each items as item}
<tr
><td>{item.key}</td><td
>{item.value}</td
></tr
>
{/each}
</tbody>
</table>
</div>
{/each}
</div>
</div>
{/if}
{/snippet}
<div class="tables-container">
<div class="super-container">
{@render ConfigTable("Search", searchConfig)}
{@render ConfigTable("UGV", ugvConfig)}
{@render ConfigTable("UAV", uavConfig)}
@@ -466,13 +550,26 @@
<p>No log.txt file found for this simulation.</p>
{/if}
{#if flightPathVideo || cameraVideo}
{#if flightPathVideo || gazeboVideo}
<hr />
<h2>Flight Path & Camera</h2>
<h2>Flight Path & Gazebo</h2>
<DualVideoViewer
simName={simulation.name}
video1={flightPathVideo}
video2={cameraVideo}
video2={gazeboVideo}
{getResourceUrl}
{isEditing}
onDelete={handleDeleteResource}
/>
{/if}
{#if uavCamera || ugvCamera}
<hr />
<h2>UAV & UGV Cameras</h2>
<DualVideoViewer
simName={simulation.name}
video1={uavCamera}
video2={ugvCamera}
{getResourceUrl}
{isEditing}
onDelete={handleDeleteResource}
@@ -509,4 +606,3 @@
<p>No other media resources found for this simulation.</p>
{/if}
{/if}

View File

@@ -31,13 +31,19 @@
);
let searchConfig = $derived(
parsedConfig?.filter((c) => c.key.startsWith("search.")),
parsedConfig
?.filter((c) => c.key.startsWith("search."))
.map((c) => ({ ...c, key: c.key.replace("search.", "") })),
);
let uavConfig = $derived(
parsedConfig?.filter((c) => c.key.startsWith("uav.")),
parsedConfig
?.filter((c) => c.key.startsWith("uav."))
.map((c) => ({ ...c, key: c.key.replace("uav.", "") })),
);
let ugvConfig = $derived(
parsedConfig?.filter((c) => c.key.startsWith("ugv.")),
parsedConfig
?.filter((c) => c.key.startsWith("ugv."))
.map((c) => ({ ...c, key: c.key.replace("ugv.", "") })),
);
let otherConfig = $derived(
parsedConfig?.filter(
@@ -48,6 +54,25 @@
),
);
function groupConfig(data: { key: string; value: string }[] | undefined) {
if (!data) return {};
const groups: Record<string, { key: string; value: string }[]> = {
_general: [],
};
for (const item of data) {
const parts = item.key.split(".");
if (parts.length > 1) {
const groupName = parts[0];
const subKey = parts.slice(1).join(".");
if (!groups[groupName]) groups[groupName] = [];
groups[groupName].push({ key: subKey, value: item.value });
} else {
groups["_general"].push(item);
}
}
return groups;
}
let imagesList = $derived<string[]>(
simulation && simulation.resources
? simulation.resources.filter(isImage)
@@ -116,55 +141,77 @@
{#if simulation.config}
<div class="config-box">
<strong>Configuration Options:</strong>
<div class="tables-container">
{#if searchConfig && searchConfig.length > 0}
<div class="table-wrapper">
<h3>Search</h3>
<table class="config-table">
<tbody>
{#each searchConfig as item}
<tr
><td>{item.key}</td><td
>{item.value}</td
></tr
{#snippet ConfigTable(
title: string,
data: { key: string; value: string }[] | undefined,
)}
{#if data && data.length > 0}
{@const groups = groupConfig(data)}
<div class="config-category">
<h2>{title}</h2>
<div class="tables-container">
{#if groups["_general"].length > 0}
<div class="table-wrapper">
<h3 class="table-label">General</h3>
<table class="config-table">
<thead>
<tr
><th>Parameter</th><th
>Value</th
></tr
>
</thead>
<tbody>
{#each groups["_general"] as item}
<tr
><td>{item.key}</td><td
>{item.value}</td
></tr
>
{/each}
</tbody>
</table>
</div>
{/if}
{#each Object.entries(groups).filter(([k]) => k !== "_general") as [groupName, items]}
<div class="table-wrapper">
<h3
class="table-label"
style="text-transform: capitalize;"
>
{/each}
</tbody>
</table>
</div>
{/if}
{#if ugvConfig && ugvConfig.length > 0}
<div class="table-wrapper">
<h3>UGV</h3>
<table class="config-table">
<tbody>
{#each ugvConfig as item}
<tr
><td>{item.key}</td><td
>{item.value}</td
></tr
>
{/each}
</tbody>
</table>
</div>
{/if}
{#if uavConfig && uavConfig.length > 0}
<div class="table-wrapper">
<h3>UAV</h3>
<table class="config-table">
<tbody>
{#each uavConfig as item}
<tr
><td>{item.key}</td><td
>{item.value}</td
></tr
>
{/each}
</tbody>
</table>
{groupName}
</h3>
<table class="config-table">
<thead>
<tr
><th>Parameter</th><th
>Value</th
></tr
>
</thead>
<tbody>
{#each items as item}
<tr
><td>{item.key}</td><td
>{item.value}</td
></tr
>
{/each}
</tbody>
</table>
</div>
{/each}
</div>
</div>
{/if}
{/snippet}
<div class="super-container">
{@render ConfigTable("Search", searchConfig)}
{@render ConfigTable("UGV", ugvConfig)}
{@render ConfigTable("UAV", uavConfig)}
{@render ConfigTable("Other", otherConfig)}
</div>
</div>
{/if}
@@ -226,6 +273,28 @@
break-inside: avoid;
}
.super-container {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 15px;
}
.config-category {
padding: 15px;
background: #ffffff;
border: 1px solid #ddd;
border-radius: 8px;
}
.config-category h2 {
margin-top: 0;
margin-bottom: 10px;
font-size: 18px;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
.tables-container {
display: flex;
flex-wrap: wrap;