feature: Simulation Control Center (Dashboard)
CI / backend (push) Successful in 8m43s
CI / frontend (push) Successful in 2m0s

This commit is contained in:
voltsrage
2026-08-06 02:22:55 +08:00
parent 24f45851e9
commit 1d28880920
91 changed files with 1943 additions and 25 deletions
@@ -0,0 +1,297 @@
<script setup>
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useSimulationStore } from '@/stores/simulation'
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { formatWallClockForSpeed } from '@/composables/simulationFormat'
const SPEED_OPTIONS = [
{ label: 'Real time', multiplier: 1 },
{ label: 'Fast', multiplier: 60 },
{ label: 'Very fast', multiplier: 240 },
{ label: 'Instant', multiplier: 600 },
]
const simulationStore = useSimulationStore()
const {
scenarios,
activeRuns,
recentRuns,
loading,
error,
starting,
atConcurrencyLimit,
maxConcurrentRuns,
maxSpeed,
} = storeToRefs(simulationStore)
const selectedScenarioId = ref(null)
const searchQuery = ref('')
const activeTag = ref(null)
const speed = ref(60)
const stoppingId = ref(null)
const availableSpeeds = computed(() => {
const cap = maxSpeed.value
return SPEED_OPTIONS.map((opt) => {
const multiplier = cap != null ? Math.min(opt.multiplier, cap) : opt.multiplier
return { ...opt, multiplier }
}).filter((opt, index, arr) =>
// Drop Instant (or any option) if a lower option already uses the same capped value
index === arr.findIndex(o => o.multiplier === opt.multiplier),
)
})
const selectedScenario = computed(() =>
scenarios.value.find(s => s.id === selectedScenarioId.value)
?? filteredScenarios.value[0]
?? null,
)
const allTags = computed(() => {
const set = new Set()
for (const s of scenarios.value) {
for (const tag of s.tags ?? []) set.add(tag)
}
return [...set].sort()
})
const filteredScenarios = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
return scenarios.value.filter((s) => {
if (activeTag.value && !(s.tags ?? []).includes(activeTag.value)) return false
if (!q) return true
const haystack = [
s.name,
s.description,
s.id,
s.department,
...(s.tags ?? []),
].filter(Boolean).join(' ').toLowerCase()
return haystack.includes(q)
})
})
function wallClockFor(multiplier) {
if (!selectedScenario.value?.durationMinutes) return null
return formatWallClockForSpeed(selectedScenario.value.durationMinutes, multiplier)
}
function concurrencyErrorMessage(raw) {
const n = maxConcurrentRuns.value
if (/concurrent|concurrency|409/i.test(raw ?? '')) {
return n > 0
? `Concurrency limit reached — at most ${n} simulation run${n === 1 ? '' : 's'} can run at once. Stop an active run first.`
: 'Concurrency limit reached. Stop an active run first.'
}
return raw
}
async function onStart(scenarioId) {
selectedScenarioId.value = scenarioId
try {
await simulationStore.start(scenarioId, speed.value)
} catch (e) {
simulationStore.error = concurrencyErrorMessage(e.message)
}
}
async function onStop(runId) {
stoppingId.value = runId
try {
await simulationStore.stop(runId)
} catch {
// error already on store
} finally {
stoppingId.value = null
}
}
function selectScenario(id) {
selectedScenarioId.value = id
}
function toggleTag(tag) {
activeTag.value = activeTag.value === tag ? null : tag
}
onMounted(async () => {
await Promise.all([
simulationStore.loadScenarios(),
simulationStore.refreshRuns(),
])
simulationStore.startPolling()
if (!selectedScenarioId.value && scenarios.value[0]) {
selectedScenarioId.value = scenarios.value[0].id
}
if (!availableSpeeds.value.some(o => o.multiplier === speed.value)) {
const preferred = availableSpeeds.value.find(o => o.multiplier === 60)
?? availableSpeeds.value[1]
?? availableSpeeds.value[0]
if (preferred) speed.value = preferred.multiplier
}
})
onBeforeUnmount(() => {
simulationStore.stopPolling()
})
</script>
<template>
<div class="space-y-8">
<div>
<h1 class="text-xl font-bold dark:text-white">Simulation</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Replay a recorded clinical scenario into this ward. All patients created here are simulated.
</p>
</div>
<div
v-if="error"
role="alert"
class="flex items-start justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
>
<p>{{ error }}</p>
<button
type="button"
class="shrink-0 font-medium underline"
@click="simulationStore.clearError()"
>
Dismiss
</button>
</div>
<SimulationRunPanel
v-if="activeRuns.length || recentRuns.length"
:active-runs="activeRuns"
:recent-runs="recentRuns"
:stopping-id="stoppingId"
@stop="onStop"
/>
<section aria-labelledby="sim-speed-heading">
<h2
id="sim-speed-heading"
class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Replay speed
</h2>
<div
class="inline-flex max-w-full flex-wrap rounded-lg border border-gray-200 bg-white p-1 dark:border-gray-700 dark:bg-gray-900"
role="group"
aria-label="Replay speed"
>
<button
v-for="opt in availableSpeeds"
:key="opt.label"
type="button"
class="min-h-11 rounded-md px-4 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="speed === opt.multiplier
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800'"
:aria-pressed="speed === opt.multiplier"
@click="speed = opt.multiplier"
>
<span class="font-medium">{{ opt.label }}</span>
<span
class="mt-0.5 block text-xs"
:class="speed === opt.multiplier ? 'text-blue-100' : 'text-gray-500 dark:text-gray-400'"
>
<template v-if="wallClockFor(opt.multiplier)">
{{ wallClockFor(opt.multiplier) }} for selected scenario
</template>
<template v-else>
{{ opt.multiplier }}×
</template>
</span>
</button>
</div>
<p v-if="selectedScenario" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Durations above use
<span class="font-medium text-gray-700 dark:text-gray-300">{{ selectedScenario.name }}</span>
({{ selectedScenario.durationMinutes }} simulated minutes).
</p>
</section>
<section aria-labelledby="sim-catalogue-heading">
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
<h2
id="sim-catalogue-heading"
class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Scenario catalogue
</h2>
</div>
<div class="mb-4 space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<label class="block">
<span class="sr-only">Search scenarios</span>
<input
v-model="searchQuery"
type="search"
placeholder="Search scenarios by name, tag, or department…"
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
>
</label>
<div v-if="allTags.length" class="flex flex-wrap items-center gap-2">
<Button
v-for="tag in allTags"
:key="tag"
size="sm"
:variant="activeTag === tag ? 'primary' : 'secondary'"
@click="toggleTag(tag)"
>
{{ tag }}
</Button>
<Button
v-if="activeTag"
size="sm"
variant="ghost"
@click="activeTag = null"
>
Clear filter
</Button>
</div>
</div>
<Skeleton v-if="loading && scenarios.length === 0" :rows="3" />
<EmptyState
v-else-if="scenarios.length === 0"
message="No scenarios found. Check that Simulation:ScenarioDirectory is configured and contains scenario JSON files."
/>
<EmptyState
v-else-if="filteredScenarios.length === 0"
message="No scenarios match the current search or tag filter."
/>
<div
v-else
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
role="list"
>
<div
v-for="scenario in filteredScenarios"
:key="scenario.id"
role="listitem"
>
<ScenarioCard
:scenario="scenario"
:selected="selectedScenarioId === scenario.id"
:at-concurrency-limit="atConcurrencyLimit"
:max-concurrent-runs="maxConcurrentRuns"
:starting="starting"
@select="selectScenario"
@start="onStart"
/>
</div>
</div>
</section>
</div>
</template>