Files
sim-link/src/routes/simulation/[id]/+page.svelte
2026-02-21 22:41:27 -05:00

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">&lt;&lt; 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>