Compare commits

...
2 Commits
Author SHA1 Message Date
matitos def86d7f56 Retry the strategy list and make collapse obvious
CI/CD / backend (push) Successful in 5m14s
CI/CD / frontend (push) Successful in 5m16s
CI/CD / build (push) Successful in 17s
CI/CD / cleanup (push) Successful in 4s
CI/CD / deploy (push) Successful in 50s
2026-09-21 17:11:18 +00:00
matitos b9c69c546c Retry the strategy list and make collapse obvious
- The strategy/preset lists were loaded once with failures swallowed, so a
  single failed first request left the strategy dropdown empty forever. They
  are now retried every 3s until populated.
- Clearer collapse affordance: hover highlight, a hide/show hint, and a
  tooltip; Backtest and Parameter sweep start collapsed so the strategy
  selector and status are immediately visible.
- Adds an integration test that the strategy dropdown is populated and the
  panel actually collapses.
2026-09-21 17:11:18 +00:00
5 changed files with 69 additions and 6 deletions

No files matched your search

+16 -3
View File
@@ -55,6 +55,7 @@ let socket
let pollTimer
let statusTimer
let graceTimer
let listTimer
async function refreshAll() {
const [p, o, e, q] = await Promise.all([
@@ -183,11 +184,15 @@ onMounted(() => {
if (!connected.value) pollingFallback.value = true
}, 6000)
// Initial loads are best-effort; the intervals below keep retrying.
// Initial loads are best-effort.
api.getStatus().then((s) => (status.value = s)).catch(() => {})
refreshAll().catch(() => {})
loadStrategies().catch(() => {})
loadPresets().catch(() => {})
// The strategy list and presets rarely change, so load them once but keep
// retrying until they arrive: a single failed first request used to leave the
// strategy dropdown empty forever.
loadStrategies()
loadPresets()
// Poll fast enough that the UI feels live even when it is the only channel.
pollTimer = setInterval(() => refreshAll().catch(() => {}), 3000)
@@ -195,12 +200,20 @@ onMounted(() => {
() => api.getStatus().then((s) => (status.value = s)).catch(() => {}),
3000,
)
// Retry the static lists until non-empty (covers a failed first load and a
// backend that was still starting up).
listTimer = setInterval(() => {
if (!strategies.value.length) loadStrategies()
if (!presets.value.length) loadPresets()
if (strategies.value.length && presets.value.length) clearInterval(listTimer)
}, 3000)
})
onUnmounted(() => {
socket?.close()
clearInterval(pollTimer)
clearInterval(statusTimer)
clearInterval(listTimer)
clearTimeout(graceTimer)
})
</script>
+1 -1
View File
@@ -37,7 +37,7 @@ async function run(payload) {
</script>
<template>
<CollapsiblePanel title="Backtest" storage-key="backtest" :summary="summary">
<CollapsiblePanel title="Backtest" storage-key="backtest" :summary="summary" :default-open="false">
<div class="space-y-3">
<StrategyForm
:strategies="strategies"
+3 -1
View File
@@ -39,8 +39,9 @@ watch(open, (value) => {
<section class="card">
<button
type="button"
class="flex w-full items-center justify-between gap-3 text-left"
class="-m-2 flex w-full items-center justify-between gap-3 rounded-lg p-2 text-left transition hover:bg-slate-700/40"
:aria-expanded="open"
:title="open ? 'Collapse' : 'Expand'"
@click="open = !open"
>
<span class="flex items-center gap-2 font-semibold">
@@ -54,6 +55,7 @@ watch(open, (value) => {
<path d="M6 3.5 11 8l-5 4.5z" />
</svg>
{{ title }}
<span class="text-xs font-normal text-slate-500">{{ open ? 'hide' : 'show' }}</span>
</span>
<span v-if="!open && summary" class="truncate text-xs font-normal text-slate-400">
{{ summary }}
+1 -1
View File
@@ -92,7 +92,7 @@ async function run() {
</script>
<template>
<CollapsiblePanel title="Parameter sweep" storage-key="sweep" :summary="summary">
<CollapsiblePanel title="Parameter sweep" storage-key="sweep" :summary="summary" :default-open="false">
<div class="space-y-3">
<div class="grid gap-2 sm:grid-cols-4">
<label class="space-y-1">
+48
View File
@@ -0,0 +1,48 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import StrategyPanel from './components/StrategyPanel.vue'
function mountPanel() {
return mount(StrategyPanel, {
props: {
strategies: [
{
key: 'sma_cross',
name: 'SMA Crossover',
description: 'test',
params: [{ name: 'fast', type: 'int', default: 10, description: '' }],
},
],
status: { running: false, symbol: 'BTC/USDT', timeframe: '5m', open_positions: 0 },
busy: false,
presets: [],
},
})
}
describe('StrategyPanel collapse', () => {
it('shows the strategy form by default', () => {
const wrapper = mountPanel()
expect(wrapper.find('select').exists()).toBe(true)
})
it('hides the form when the header is clicked', async () => {
const wrapper = mountPanel()
const header = wrapper.find('button')
expect(header.attributes('aria-expanded')).toBe('true')
await header.trigger('click')
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false')
// The form container is v-show-hidden, so the select is no longer visible.
const hidden = wrapper.findAll('div').some((d) => d.element.style.display === 'none')
expect(hidden).toBe(true)
})
it('picks the first strategy so the dropdown is populated', () => {
const wrapper = mountPanel()
const select = wrapper.find('select')
expect(select.element.value).toBe('sma_cross')
expect(select.findAll('option')).toHaveLength(1)
})
})