diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3ed062e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto eol=lf + +*.html text eol=lf +*.ini text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.vue text eol=lf +*.ts text eol=lf + +*.ico binary +*.png binary diff --git a/components/data/DownloadForm.vue b/components/data/DownloadForm.vue index 36b4afa..ad97155 100644 --- a/components/data/DownloadForm.vue +++ b/components/data/DownloadForm.vue @@ -13,6 +13,8 @@ v-model="formState.exchangeId" :items="downloadStore.exchanges" :placeholder="t('data.download.form.exchange.placeholder')" + :loading="downloadStore.exchangesStatus === 'loading'" + :disabled="downloadStore.exchangesStatus !== 'success'" /> @@ -87,6 +89,10 @@ const symbolsForSelectedExchange = computed(() => { return downloadStore.symbolsByExchange[formState.exchangeId]?.list ?? []; }); +onMounted(() => { + downloadStore.fetchExchanges(); +}); + watch(() => formState.exchangeId, (newExchangeId) => { if (newExchangeId) { formState.symbols = []; // Reset symbols when exchange changes diff --git a/components/data/ProgressList.vue b/components/data/ProgressList.vue index b51e763..b93e69e 100644 --- a/components/data/ProgressList.vue +++ b/components/data/ProgressList.vue @@ -2,20 +2,36 @@

{{ t('data.download.progress.title') }}

-
+
- + +

{{ job.symbol }} [{{ job.timeframe }}] - - {{ job.message }}

- + +
+
+

+ {{ formatJobMessage(job.message) }} +

+
@@ -26,4 +42,17 @@ diff --git a/components/view/DataManagement.vue b/components/view/DataManagement.vue index 9983b66..386aa7e 100644 --- a/components/view/DataManagement.vue +++ b/components/view/DataManagement.vue @@ -50,7 +50,7 @@
- + diff --git a/stores/dataAvailabilityStore.ts b/stores/dataAvailabilityStore.ts index e57a836..1796ff0 100644 --- a/stores/dataAvailabilityStore.ts +++ b/stores/dataAvailabilityStore.ts @@ -1,59 +1,59 @@ -export const useDataAvailabilityStore = defineStore('dataAvailability', () => { - const manifest = ref([]); - const status = ref('idle'); - const toast = useToast(); - - const isLoading = computed(() => status.value === 'loading'); - - async function fetchManifest() { - if (manifest.value.length > 0) return; - - status.value = 'loading'; - try { - manifest.value = await $fetch('/api/data-availability'); - status.value = 'success'; - } catch (e: any) { - toast.add({ - title: 'Error Loading Market Data', - description: e.data?.error || 'Could not connect to the API to get available market data.', - color: 'error', - icon: 'i-heroicons-exclamation-triangle', - }); - console.error('Failed to fetch data availability manifest:', e); - status.value = 'error'; - } - } - - const availableSymbols = computed(() => manifest.value.map(item => item.symbol).sort()); - - const getTimeframesForSymbol = (symbol: string | null): TimeframeData[] => { - if (!symbol) return []; - const symbolData = manifest.value.find(item => item.symbol === symbol); - return symbolData?.timeframes ?? []; - }; - - const getDateRangeFor = (symbol: string | null, timeframe: string | null): { min?: string, max?: string } => { - if (!symbol || !timeframe) return {}; - - const symbolTimeframes = getTimeframesForSymbol(symbol); - const timeframeData = symbolTimeframes.find(tf => tf.timeframe === timeframe); - - if (!timeframeData) return {}; - - return { - min: timeframeData.startDate.split('T')[0], - max: timeframeData.endDate.split('T')[0], - }; - }; - - - return { - manifest, - status, - isLoading, - fetchManifest, - availableSymbols, - getTimeframesForSymbol, - getDateRangeFor, - }; -}); +export const useDataAvailabilityStore = defineStore('dataAvailability', () => { + const manifest = ref([]); + const status = ref('idle'); + const toast = useToast(); + + const isLoading = computed(() => status.value === 'loading'); + + async function fetchManifest(force = false) { + if (manifest.value.length > 0 && !force) return; + + status.value = 'loading'; + try { + manifest.value = await $fetch('/api/data-availability'); + status.value = 'success'; + } catch (e: any) { + toast.add({ + title: 'Error Loading Market Data', + description: e.data?.error || 'Could not connect to the API to get available market data.', + color: 'error', + icon: 'i-heroicons-exclamation-triangle', + }); + console.error('Failed to fetch data availability manifest:', e); + status.value = 'error'; + } + } + + const availableSymbols = computed(() => manifest.value.map(item => item.symbol).sort()); + + const getTimeframesForSymbol = (symbol: string | null): TimeframeData[] => { + if (!symbol) return []; + const symbolData = manifest.value.find(item => item.symbol === symbol); + return symbolData?.timeframes ?? []; + }; + + const getDateRangeFor = (symbol: string | null, timeframe: string | null): { min?: string, max?: string } => { + if (!symbol || !timeframe) return {}; + + const symbolTimeframes = getTimeframesForSymbol(symbol); + const timeframeData = symbolTimeframes.find(tf => tf.timeframe === timeframe); + + if (!timeframeData) return {}; + + return { + min: timeframeData.startDate.split('T')[0], + max: timeframeData.endDate.split('T')[0], + }; + }; + + + return { + manifest, + status, + isLoading, + fetchManifest, + availableSymbols, + getTimeframesForSymbol, + getDateRangeFor, + }; +}); diff --git a/stores/dataDownloadStore.ts b/stores/dataDownloadStore.ts index 723f3c7..08dc113 100644 --- a/stores/dataDownloadStore.ts +++ b/stores/dataDownloadStore.ts @@ -1,9 +1,11 @@ export const useDataDownloadStore = defineStore('dataDownload', () => { const toast = useToast(); const { public: { apiUrl } } = useRuntimeConfig(); + const dataAvailabilityStore = useDataAvailabilityStore(); // --- State --- - const exchanges = ref(['okx', 'binance', 'bybit']); // Mocked exchange list + const exchanges = ref([]); + const exchangesStatus = ref('idle'); const symbolsByExchange = reactive>({}); const downloadQueue = ref([]); const isQueueRunning = ref(false); @@ -11,7 +13,30 @@ export const useDataDownloadStore = defineStore('dataDownload', () => { // --- Actions --- /** - * Fetches (currently mocked) symbols for a given exchange. + * Fetches the list of available exchanges from the API. + */ + async function fetchExchanges() { + if (exchanges.value.length > 0 || exchangesStatus.value === 'loading') { + return; + } + + exchangesStatus.value = 'loading'; + try { + exchanges.value = await $fetch('/api/data/exchanges'); + exchangesStatus.value = 'success'; + } catch (e: any) { + exchangesStatus.value = 'error'; + toast.add({ + title: 'Error Fetching Exchanges', + description: e.data?.error || 'Could not fetch the list of exchanges.', + color: 'error', + icon: 'i-heroicons-exclamation-triangle-20-solid', + }); + } + } + + /** + * Fetches symbols for a given exchange. */ async function fetchSymbols(exchangeId: string) { if (!exchangeId || symbolsByExchange[exchangeId]?.status === 'loading') { @@ -107,6 +132,29 @@ export const useDataDownloadStore = defineStore('dataDownload', () => { } } + /** + * Sends a cancellation request for a specific job. + * @param jobId The ID of the job to cancel. + */ + async function cancelDownload(jobId: string) { + try { + await $fetch(`/api/data/download/${jobId}`, { + method: 'DELETE' + }); + toast.add({ + title: 'Cancellation Requested', + description: `A request to cancel job ${jobId} has been sent.`, + color: 'info' + }); + } catch (e: any) { + toast.add({ + title: 'Cancellation Error', + description: e.data?.error || `Could not request cancellation for job ${jobId}.`, + color: 'error' + }); + } + } + /** * Listens to a Mercure stream for a specific download job. */ @@ -122,11 +170,12 @@ export const useDataDownloadStore = defineStore('dataDownload', () => { job.progress = data.progress || job.progress; job.message = data.message || job.message; - if (data.status === 'completed' || data.status === 'failed') { + if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') { job.status = data.status; eventSource.close(); - downloadQueue.value.shift(); // Remove completed/failed job - processQueue(); // Process next in queue + downloadQueue.value.shift(); + dataAvailabilityStore.fetchManifest(true); + processQueue(); resolve(); } }; @@ -136,6 +185,7 @@ export const useDataDownloadStore = defineStore('dataDownload', () => { job.message = 'Connection to progress stream failed.'; eventSource.close(); downloadQueue.value.shift(); + dataAvailabilityStore.fetchManifest(true); processQueue(); resolve(); }; @@ -144,10 +194,13 @@ export const useDataDownloadStore = defineStore('dataDownload', () => { return { exchanges, + exchangesStatus, symbolsByExchange, downloadQueue, isQueueRunning, + fetchExchanges, fetchSymbols, queueDownloads, + cancelDownload, }; }); diff --git a/types/DownloadJob.d.ts b/types/DownloadJob.d.ts index 95c1526..4339fda 100644 --- a/types/DownloadJob.d.ts +++ b/types/DownloadJob.d.ts @@ -5,7 +5,7 @@ declare interface DownloadJob { timeframe: string; startDate: string; endDate: string; - status: 'pending' | 'downloading' | 'completed' | 'failed'; + status: 'pending' | 'downloading' | 'completed' | 'failed' | 'cancelled'; progress: number; message: string; jobId?: string; // Mercure Job ID