570 lines
16 KiB
Svelte
570 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import MediaItem from "$lib/MediaItem.svelte";
|
|
import LogViewer from "$lib/LogViewer.svelte";
|
|
import ImageCarousel from "$lib/ImageCarousel.svelte";
|
|
import DualVideoViewer from "$lib/DualVideoViewer.svelte";
|
|
import { formatDate } from "$lib/simulationState.svelte";
|
|
|
|
let { data } = $props();
|
|
let id = $derived(data.id);
|
|
|
|
type SimulationDetails = {
|
|
id: number;
|
|
name: string;
|
|
created_at: string;
|
|
resources: string[];
|
|
config: string | null;
|
|
search_time: number | null;
|
|
total_time: number | null;
|
|
total_size_bytes: number;
|
|
};
|
|
|
|
let simulation = $state<SimulationDetails | null>(null);
|
|
let error = $state("");
|
|
|
|
function formatBytes(bytes: number) {
|
|
if (bytes === 0) return "0 Bytes";
|
|
const k = 1024;
|
|
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
}
|
|
|
|
function flattenJSON(
|
|
obj: any,
|
|
prefix = "",
|
|
): { key: string; value: string }[] {
|
|
let result: { key: string; value: string }[] = [];
|
|
if (!obj) return result;
|
|
for (let key in obj) {
|
|
if (typeof obj[key] === "object" && obj[key] !== null) {
|
|
if (Array.isArray(obj[key])) {
|
|
result.push({
|
|
key: prefix + key,
|
|
value: JSON.stringify(obj[key]),
|
|
});
|
|
} else {
|
|
result = result.concat(
|
|
flattenJSON(obj[key], prefix + key + "."),
|
|
);
|
|
}
|
|
} else {
|
|
result.push({ key: prefix + key, value: String(obj[key]) });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
let parsedConfig: { key: string; value: string }[] | null = $derived.by(
|
|
() => {
|
|
if (!simulation?.config) return null;
|
|
try {
|
|
let cleanedConfig = simulation.config.replace(
|
|
/"([^"]+)\.yaml"\s*:/g,
|
|
'"$1":',
|
|
);
|
|
return flattenJSON(JSON.parse(cleanedConfig));
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
);
|
|
|
|
let searchConfig = $derived(
|
|
parsedConfig?.filter((c) => c.key.startsWith("search.")),
|
|
);
|
|
let uavConfig = $derived(
|
|
parsedConfig?.filter((c) => c.key.startsWith("uav.")),
|
|
);
|
|
let ugvConfig = $derived(
|
|
parsedConfig?.filter((c) => c.key.startsWith("ugv.")),
|
|
);
|
|
let otherConfig = $derived(
|
|
parsedConfig?.filter(
|
|
(c) =>
|
|
!c.key.startsWith("search.") &&
|
|
!c.key.startsWith("uav.") &&
|
|
!c.key.startsWith("ugv."),
|
|
),
|
|
);
|
|
|
|
function isImage(fname: string) {
|
|
return (
|
|
fname.toLowerCase().endsWith(".png") ||
|
|
fname.toLowerCase().endsWith(".jpg") ||
|
|
fname.toLowerCase().endsWith(".jpeg")
|
|
);
|
|
}
|
|
|
|
function isVideo(fname: string) {
|
|
return (
|
|
fname.toLowerCase().endsWith(".mp4") ||
|
|
fname.toLowerCase().endsWith(".avi") ||
|
|
fname.toLowerCase().endsWith(".webm")
|
|
);
|
|
}
|
|
|
|
let flightPathVideo: string | undefined = $derived(
|
|
simulation && simulation.resources
|
|
? simulation.resources.find(
|
|
(res: string) => res.includes("flight_path") && isVideo(res),
|
|
)
|
|
: undefined,
|
|
);
|
|
|
|
let cameraVideo: string | undefined = $derived(
|
|
simulation && simulation.resources
|
|
? simulation.resources.find(
|
|
(res: string) => res.includes("camera") && isVideo(res),
|
|
)
|
|
: undefined,
|
|
);
|
|
|
|
let imagesList = $derived<string[]>(
|
|
simulation && simulation.resources
|
|
? simulation.resources.filter(isImage)
|
|
: [],
|
|
);
|
|
let otherResourcesList = $derived<string[]>(
|
|
simulation && simulation.resources
|
|
? simulation.resources.filter(
|
|
(res: string) =>
|
|
!isImage(res) &&
|
|
res !== "log.txt" &&
|
|
res !== flightPathVideo &&
|
|
res !== cameraVideo,
|
|
)
|
|
: [],
|
|
);
|
|
|
|
function getResourceUrl(simName: string, resourceName: string) {
|
|
return `/results/${simName}/${resourceName}`;
|
|
}
|
|
|
|
onMount(async () => {
|
|
try {
|
|
const res = await fetch(`/api/simulations/${id}`);
|
|
if (!res.ok) throw new Error("Failed to fetch simulation details");
|
|
simulation = await res.json();
|
|
newName = simulation!.name;
|
|
} catch (e: any) {
|
|
error = e.message;
|
|
}
|
|
});
|
|
|
|
let isEditing = $state(false);
|
|
let newName = $state("");
|
|
let uploadFiles = $state<FileList | null>(null);
|
|
let saveError = $state("");
|
|
|
|
async function handleSave() {
|
|
if (!simulation) return;
|
|
saveError = "";
|
|
|
|
// 1. Rename logic
|
|
if (newName.trim() !== simulation.name) {
|
|
const renameRes = await fetch(`/api/simulations/${id}/rename`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: newName }),
|
|
});
|
|
|
|
if (!renameRes.ok) {
|
|
const text = await renameRes.text();
|
|
saveError = text || "Failed to rename simulation.";
|
|
return;
|
|
}
|
|
|
|
const renameData = await renameRes.json();
|
|
simulation.name = renameData.new_name;
|
|
}
|
|
|
|
// 2. Upload Logic
|
|
if (uploadFiles && uploadFiles.length > 0) {
|
|
for (let i = 0; i < uploadFiles.length; i++) {
|
|
const formData = new FormData();
|
|
formData.append("file", uploadFiles[i]);
|
|
|
|
const uploadRes = await fetch(`/api/simulations/${id}/upload`, {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (!uploadRes.ok) {
|
|
saveError = `Failed to upload: ${uploadFiles[i].name}`;
|
|
return;
|
|
}
|
|
|
|
const upData = await uploadRes.json();
|
|
if (!simulation.resources.includes(upData.filename)) {
|
|
simulation.resources = [
|
|
...simulation.resources,
|
|
upData.filename,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
isEditing = false;
|
|
uploadFiles = null;
|
|
}
|
|
|
|
function cancelEdit() {
|
|
if (simulation) {
|
|
newName = simulation.name;
|
|
}
|
|
isEditing = false;
|
|
saveError = "";
|
|
uploadFiles = null;
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (
|
|
!confirm(
|
|
"Are you sure you want to permanently delete this simulation and all its associated media files?",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
const res = await fetch(`/api/simulations/${id}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
if (res.ok) {
|
|
// Force client-side redirect back to cleanly wiped root
|
|
window.location.href = "/";
|
|
} else {
|
|
const text = await res.text();
|
|
alert("Failed to delete the simulation: " + text);
|
|
}
|
|
}
|
|
</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}
|
|
<div class="header-container">
|
|
{#if !isEditing}
|
|
<h1>Simulation Detail: {simulation.name}</h1>
|
|
<button class="edit-btn" onclick={() => (isEditing = true)}
|
|
>Edit</button
|
|
>
|
|
<button class="delete-btn" onclick={handleDelete}>Delete</button>
|
|
{:else}
|
|
<div class="edit-form">
|
|
<h2>Edit Simulation</h2>
|
|
<div class="form-group">
|
|
<label for="sim-name">Name:</label>
|
|
<input id="sim-name" type="text" bind:value={newName} />
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="sim-files">Upload Media:</label>
|
|
<input
|
|
id="sim-files"
|
|
type="file"
|
|
multiple
|
|
bind:files={uploadFiles}
|
|
/>
|
|
</div>
|
|
{#if saveError}
|
|
<p class="error">{saveError}</p>
|
|
{/if}
|
|
<div class="form-actions">
|
|
<button class="save-btn" onclick={handleSave}>Save</button>
|
|
<button class="cancel-btn" onclick={cancelEdit}
|
|
>Cancel</button
|
|
>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<p>
|
|
<strong>ID:</strong>
|
|
{simulation.id}
|
|
</p>
|
|
<p>
|
|
<strong>Search Pattern:</strong>
|
|
<span style="text-transform: capitalize;"
|
|
>{parsedConfig?.find((c) => c.key === "search.spiral.max_legs")
|
|
? "Spiral"
|
|
: parsedConfig?.find((c) => c.key === "search.lawnmower.width")
|
|
? "Lawnmower"
|
|
: parsedConfig?.find((c) => c.key === "search.levy.max_steps")
|
|
? "Levy"
|
|
: "Unknown"}</span
|
|
>
|
|
</p>
|
|
<p><strong>Date Code:</strong> {formatDate(simulation.created_at)}</p>
|
|
|
|
{#if simulation.search_time !== null && simulation.total_time !== null}
|
|
<p>
|
|
<strong>Search Time:</strong>
|
|
{simulation.search_time.toFixed(2)}s | <strong>Total Time:</strong>
|
|
{simulation.total_time.toFixed(2)}s
|
|
</p>
|
|
{/if}
|
|
<p>
|
|
<strong>Total Media Size:</strong>
|
|
{formatBytes(simulation.total_size_bytes || 0)}
|
|
</p>
|
|
|
|
{#if simulation.config}
|
|
<div class="config-box">
|
|
<strong>Configuration Options:</strong>
|
|
{#if parsedConfig}
|
|
{#snippet ConfigTable(
|
|
title: string,
|
|
data: { key: string; value: string }[] | undefined,
|
|
)}
|
|
{#if data && data.length > 0}
|
|
<div class="table-wrapper">
|
|
<h3 class="table-label">{title}</h3>
|
|
<table class="config-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Parameter</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each data as item}
|
|
<tr>
|
|
<td>{item.key}</td>
|
|
<td>{item.value}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<div class="tables-container">
|
|
{@render ConfigTable("Search", searchConfig)}
|
|
{@render ConfigTable("UGV", ugvConfig)}
|
|
{@render ConfigTable("UAV", uavConfig)}
|
|
{@render ConfigTable("Other", otherConfig)}
|
|
</div>
|
|
{:else}
|
|
<pre>{simulation.config}</pre>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<hr />
|
|
<h2>Simulation Logs</h2>
|
|
{#if simulation.resources && simulation.resources.includes("log.txt")}
|
|
<LogViewer simName={simulation.name} resourceName="log.txt" />
|
|
{:else}
|
|
<p>No log.txt file found for this simulation.</p>
|
|
{/if}
|
|
|
|
{#if flightPathVideo || cameraVideo}
|
|
<hr />
|
|
<h2>Flight Path & Camera</h2>
|
|
<DualVideoViewer
|
|
simName={simulation.name}
|
|
video1={flightPathVideo}
|
|
video2={cameraVideo}
|
|
{getResourceUrl}
|
|
/>
|
|
{/if}
|
|
|
|
<hr />
|
|
|
|
<h2>Media & Results</h2>
|
|
{#if imagesList.length > 0}
|
|
<ImageCarousel
|
|
simName={simulation.name}
|
|
images={imagesList}
|
|
{getResourceUrl}
|
|
/>
|
|
{/if}
|
|
|
|
{#if otherResourcesList.length > 0}
|
|
<div class="media-container">
|
|
{#each otherResourcesList as res}
|
|
<MediaItem simName={simulation.name} resourceName={res} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if imagesList.length === 0 && otherResourcesList.length === 0}
|
|
<p>No other media 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: 100%;
|
|
box-sizing: border-box;
|
|
overflow-x: auto;
|
|
}
|
|
pre {
|
|
margin: 10px 0 0 0;
|
|
white-space: pre-wrap;
|
|
}
|
|
.table-label {
|
|
margin: 0 0 5px 0;
|
|
font-size: 16px;
|
|
text-decoration: underline;
|
|
}
|
|
.tables-container {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 20px;
|
|
align-items: flex-start;
|
|
margin-top: 10px;
|
|
}
|
|
.table-wrapper {
|
|
flex: 1 1 300px;
|
|
max-width: 100%;
|
|
overflow-x: auto;
|
|
}
|
|
.config-table {
|
|
margin-top: 10px;
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background-color: #ffffff;
|
|
}
|
|
.config-table th,
|
|
.config-table td {
|
|
border: 1px solid #000;
|
|
padding: 8px 10px;
|
|
text-align: left;
|
|
word-break: break-all;
|
|
}
|
|
.config-table th {
|
|
background-color: #e0e0e0;
|
|
}
|
|
.header-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.header-container h1 {
|
|
margin: 0;
|
|
}
|
|
.edit-btn,
|
|
.save-btn,
|
|
.cancel-btn,
|
|
.delete-btn {
|
|
padding: 6px 15px;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
border: 1px solid #000;
|
|
background-color: #e0e0e0;
|
|
font-family: inherit;
|
|
}
|
|
.edit-btn:hover,
|
|
.save-btn:hover,
|
|
.cancel-btn:hover {
|
|
background-color: #d0d0d0;
|
|
}
|
|
.delete-btn {
|
|
background-color: #ffcccc;
|
|
color: #990000;
|
|
border-color: #990000;
|
|
margin-left: auto;
|
|
}
|
|
.delete-btn:hover {
|
|
background-color: #ff9999;
|
|
}
|
|
|
|
.edit-form {
|
|
border: 1px dashed #000;
|
|
padding: 15px;
|
|
background-color: #f5f5f5;
|
|
width: 100%;
|
|
}
|
|
.edit-form h2 {
|
|
margin-top: 0;
|
|
border-bottom: none;
|
|
}
|
|
.form-group {
|
|
margin-bottom: 15px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.form-group label {
|
|
font-weight: bold;
|
|
min-width: 120px;
|
|
}
|
|
.form-group input[type="text"] {
|
|
flex: 1;
|
|
max-width: 400px;
|
|
padding: 5px;
|
|
font-family: inherit;
|
|
border: 1px solid #000;
|
|
}
|
|
.form-group input[type="file"] {
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
}
|
|
.form-group input[type="file"]::file-selector-button {
|
|
padding: 6px 15px;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
border: 1px solid #000;
|
|
background-color: #e0e0e0;
|
|
font-family: inherit;
|
|
margin-right: 10px;
|
|
}
|
|
.form-group input[type="file"]::file-selector-button:hover {
|
|
background-color: #d0d0d0;
|
|
}
|
|
.form-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
</style>
|