feature: Simulation Control Center (Dashboard)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user