Skip to content

Improve data management #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions components/data/DownloadForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
/>
</UFormField>

Expand Down Expand Up @@ -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
Expand Down
37 changes: 33 additions & 4 deletions components/data/ProgressList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,36 @@
<div v-if="downloadStore.downloadQueue.length > 0">
<h3 class="text-lg font-semibold mt-8 mb-2">{{ t('data.download.progress.title') }}</h3>
<UCard>
<div class="space-y-4">
<div class="space-y-6">
<div v-for="job in downloadStore.downloadQueue" :key="job.key">
<div class="flex justify-between items-center gap-4">
<div class="flex items-center gap-2 min-w-0">
<UIcon v-if="job.status === 'completed'" name="i-heroicons-check-circle-20-solid" class="text-green-500 w-5 h-5 flex-shrink-0" />
<UIcon v-else-if="job.status === 'downloading'" name="i-heroicons-arrow-down-tray-20-solid" class="animate-pulse w-5 h-5 flex-shrink-0" />
<UIcon v-else-if="job.status === 'downloading'" name="i-heroicons-arrow-down-tray-20-solid" class="w-5 h-5 flex-shrink-0" />
<UIcon v-else-if="job.status === 'failed'" name="i-heroicons-exclamation-circle-20-solid" class="text-red-500 w-5 h-5 flex-shrink-0" />
<UIcon v-else-if="job.status === 'cancelled'" name="i-heroicons-x-circle-20-solid" class="text-red-500 w-5 h-5 flex-shrink-0" />
<UIcon v-else name="i-heroicons-pause-circle-20-solid" class="text-gray-400 w-5 h-5 flex-shrink-0" />
<p class="text-sm truncate">
<strong>{{ job.symbol }}</strong> [{{ job.timeframe }}]
<span class="text-gray-500 dark:text-gray-400">- {{ job.message }}</span>
</p>
</div>
<UProgress v-if="job.status === 'downloading'" :value="job.progress" class="w-32" />
<UButton
v-if="job.jobId && ['pending', 'downloading'].includes(job.status)"
icon="i-heroicons-x-mark-20-solid"
size="2xs"
color="error"
variant="soft"
square
class="cursor-pointer"
:aria-label="`Cancel download for ${job.key}`"
@click="downloadStore.cancelDownload(job.jobId)"
/>
</div>
<div class="mt-1 space-y-1">
<p class="text-xs text-gray-500 dark:text-gray-400" :class="{ 'text-center': job.status === 'downloading' }">
{{ formatJobMessage(job.message) }}
</p>
<UProgress v-if="job.status === 'downloading'" v-model="job.progress" />
</div>
</div>
</div>
Expand All @@ -26,4 +42,17 @@
<script setup lang="ts">
const { t } = useI18n();
const downloadStore = useDataDownloadStore();

/**
* Formats the job message to be more concise.
* If the message contains "until", it extracts and displays only that part.
* @param message The original message from the backend.
*/
const formatJobMessage = (message: string): string => {
const untilIndex = message.toLowerCase().indexOf('up to');
if (untilIndex !== -1) {
return `Until ${message.slice(untilIndex + 6)}`;
}
return message;
};
</script>
2 changes: 1 addition & 1 deletion components/view/DataManagement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
</div>
</div>

<UAlert v-else icon="i-heroicons-circle-stack" :title="t('data.management.empty')" />
<UAlert v-else icon="i-heroicons-circle-stack" :title="t('data.management.empty')" color="neutral" variant="soft"/>
</div>
</div>
</template>
Expand Down
118 changes: 59 additions & 59 deletions stores/dataAvailabilityStore.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,59 @@
export const useDataAvailabilityStore = defineStore('dataAvailability', () => {
const manifest = ref<DataAvailabilityManifestItem[]>([]);
const status = ref<LoadingStatus>('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<DataAvailabilityManifestItem[]>('/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<DataAvailabilityManifestItem[]>([]);
const status = ref<LoadingStatus>('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<DataAvailabilityManifestItem[]>('/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,
};
});
63 changes: 58 additions & 5 deletions stores/dataDownloadStore.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,42 @@
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<string[]>([]);
const exchangesStatus = ref<LoadingStatus>('idle');
const symbolsByExchange = reactive<Record<string, { list: string[], status: LoadingStatus }>>({});
const downloadQueue = ref<DownloadJob[]>([]);
const isQueueRunning = ref(false);

// --- 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<string[]>('/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') {
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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();
}
};
Expand All @@ -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();
};
Expand All @@ -144,10 +194,13 @@ export const useDataDownloadStore = defineStore('dataDownload', () => {

return {
exchanges,
exchangesStatus,
symbolsByExchange,
downloadQueue,
isQueueRunning,
fetchExchanges,
fetchSymbols,
queueDownloads,
cancelDownload,
};
});
2 changes: 1 addition & 1 deletion types/DownloadJob.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down