Simulation Details page layout Update
This commit is contained in:
10
README.md
10
README.md
@@ -86,4 +86,12 @@ Permanently deletes a complete simulation from the platform.
|
||||
### `DELETE /api/simulations/:id/resources/:filename`
|
||||
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"}`
|
||||
- **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>}`
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
43
server/routes/stats.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,19 +265,23 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import "$lib/css/simulation.css";
|
||||
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import MediaItem from "$lib/MediaItem.svelte";
|
||||
import LogViewer from "$lib/LogViewer.svelte";
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user