Simulation Upload Update
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
<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);
|
||||
|
||||
let simulation: {
|
||||
type SimulationDetails = {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
@@ -13,18 +17,207 @@
|
||||
config: string | null;
|
||||
search_time: number | null;
|
||||
total_time: number | null;
|
||||
} | null = $state(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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<a href="/" class="back-link"><< Back to Index</a>
|
||||
@@ -34,36 +227,154 @@
|
||||
{: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>
|
||||
<div class="header-container">
|
||||
{#if !isEditing}
|
||||
<h1>Simulation Detail: {simulation.name}</h1>
|
||||
<button class="edit-btn" onclick={() => (isEditing = true)}
|
||||
>Edit</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}s | <strong>Total Time:</strong>
|
||||
{simulation.total_time}s
|
||||
{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:</strong>
|
||||
<pre>{simulation.config}</pre>
|
||||
<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 simulation.resources && simulation.resources.length > 0}
|
||||
{#if imagesList.length > 0}
|
||||
<ImageCarousel
|
||||
simName={simulation.name}
|
||||
images={imagesList}
|
||||
{getResourceUrl}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if otherResourcesList.length > 0}
|
||||
<div class="media-container">
|
||||
{#each simulation.resources as res}
|
||||
{#each otherResourcesList as res}
|
||||
<MediaItem simName={simulation.name} resourceName={res} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p>No resources found for this simulation.</p>
|
||||
{/if}
|
||||
|
||||
{#if imagesList.length === 0 && otherResourcesList.length === 0}
|
||||
<p>No other media resources found for this simulation.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -106,10 +417,121 @@
|
||||
border: 1px solid #000;
|
||||
padding: 10px;
|
||||
background-color: #f8f8f8;
|
||||
max-width: 800px;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user