diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 8eddc1b..e20a7c7 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -8,6 +8,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + ApiExample: typeof import('./src/components/ApiExample.vue')['default'] BuildServerStats: typeof import('./src/components/BuildServerStats.vue')['default'] BuildStats: typeof import('./src/components/MainNav/BuildStats.vue')['default'] CurrentlyBuilding: typeof import('./src/components/CurrentlyBuilding.vue')['default'] @@ -18,5 +19,6 @@ declare module 'vue' { QueuedPackagesList: typeof import('./src/components/CurrentlyBuilding/QueuedPackagesList.vue')['default'] StatItem: typeof import('./src/components/MainNav/BuildStats/StatItem.vue')['default'] StatsListSection: typeof import('./src/components/MainNav/BuildStats/StatsListSection.vue')['default'] + StoreExample: typeof import('./src/components/StoreExample.vue')['default'] } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6c13460..cc5f32f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,19 +4,9 @@ - -
Fetching Data
-
- - -
Can't fetch data. Please try again later
-
- - + + +
@@ -27,23 +17,41 @@ import MainNav from '@/components/MainNav.vue' import BuildServerStats from '@/components/BuildServerStats.vue' import CurrentlyBuilding from '@/components/CurrentlyBuilding.vue' import Packages from '@/components/Packages.vue' -import { useDataStore } from '@/stores/dataStore' -import { computed, onMounted, onUnmounted } from 'vue' +import { useStatsStore } from '@/stores/statsStore' +import { onBeforeMount, onUnmounted } from 'vue' +import { usePackagesStore } from '@/stores' -const dataStore = useDataStore() +const statsStore = useStatsStore() +const packagesStore = usePackagesStore() -const isLoading = computed(() => dataStore.loading) -const error = computed(() => dataStore.error) -const lastUpdated = computed(() => { - if (!dataStore.data) return null - return dataStore.data.lastUpdated -}) +let refreshInterval: number | null = null +const startAutoRefresh = (intervalMinutes = Number(import.meta.env.VITE_UPDATE_INTERVAL) || 5) => { + stopAutoRefresh() + refreshInterval = window.setInterval( + () => { + statsStore.fetchStats() + packagesStore.fetchPackages() + packagesStore.fetchCurrentlyBuilding() + }, + intervalMinutes * 60 * 1000 + ) +} -onMounted(async () => { - await dataStore.startAutoRefresh() +const stopAutoRefresh = () => { + if (refreshInterval !== null) { + clearInterval(refreshInterval) + refreshInterval = null + } +} + +onBeforeMount(() => { + statsStore.fetchStats() + packagesStore.fetchPackages(true) + packagesStore.fetchCurrentlyBuilding() + startAutoRefresh() }) onUnmounted(() => { - dataStore.stopAutoRefresh() + stopAutoRefresh() }) diff --git a/frontend/src/components/BuildServerStats.vue b/frontend/src/components/BuildServerStats.vue index 1c8f93d..06ec68d 100644 --- a/frontend/src/components/BuildServerStats.vue +++ b/frontend/src/components/BuildServerStats.vue @@ -15,11 +15,9 @@ import { computed } from 'vue' const { width } = useDisplay() -// Configuration constants const GRAPH_HEIGHT = 335 const NUMBER_OF_GRAPHS = 4 -// Computed property for responsive iframe height const iframeHeight = computed(() => width.value <= 800 ? `${NUMBER_OF_GRAPHS * GRAPH_HEIGHT}px` : '420px' ) diff --git a/frontend/src/components/CurrentlyBuilding.vue b/frontend/src/components/CurrentlyBuilding.vue index 79be32d..022c01f 100644 --- a/frontend/src/components/CurrentlyBuilding.vue +++ b/frontend/src/components/CurrentlyBuilding.vue @@ -13,7 +13,7 @@ :class=" updateFailed ? 'pulsating-circle-error' - : packageCount.building > 0 + : packageArrays.building.length > 0 ? 'pulsating-circle-amber' : 'pulsating-circle-green' " @@ -22,17 +22,21 @@ {{ updateFailed ? 'Could not fetch data.' - : packageCount.building > 0 + : packageArrays.building.length > 0 ? 'Building' : 'Idle' }} - + - +
+ + Last updated + {{ formatTimeAgo(lastUpdatedSeconds) }} + + + Last Mirror sync + {{ formatTimeAgo(lastMirrorSync) }} + +
- + Building - + @@ -74,20 +80,22 @@

Queued

- +
diff --git a/frontend/src/components/MainNav/BuildStats/StatItem.vue b/frontend/src/components/MainNav/BuildStats/StatItem.vue index e8e1449..a31cb42 100644 --- a/frontend/src/components/MainNav/BuildStats/StatItem.vue +++ b/frontend/src/components/MainNav/BuildStats/StatItem.vue @@ -1,13 +1,18 @@ diff --git a/frontend/src/components/MainNav/BuildStats/StatsListSection.vue b/frontend/src/components/MainNav/BuildStats/StatsListSection.vue index c20e42a..ed3987d 100644 --- a/frontend/src/components/MainNav/BuildStats/StatsListSection.vue +++ b/frontend/src/components/MainNav/BuildStats/StatsListSection.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/components/Packages/PackageFilters.vue b/frontend/src/components/Packages/PackageFilters.vue index 9f9cf52..f05864c 100644 --- a/frontend/src/components/Packages/PackageFilters.vue +++ b/frontend/src/components/Packages/PackageFilters.vue @@ -2,7 +2,7 @@ - + import { useDisplay } from 'vuetify' import { REPO_ITEMS, ROW_HEIGHT, STATUS_ITEMS } from '@/config/constants' -import { type FilterOptions } from '@/composables/Packages/usePackageFilters' - -defineProps<{ - filterOptions: FilterOptions - totalPages: number -}>() +import { usePackagesStore } from '@/stores' +import { computed, onMounted, ref, watch } from 'vue' const { mobile } = useDisplay() +const packagesStore = usePackagesStore() + +const page = ref(1) +const pkgbase = ref() +const repo = ref() +const status = ref<{ title: string; value: string }[]>([]) +const exact = ref() + +const totalPages = computed(() => Math.ceil(packagesStore.state.total / packagesStore.state.limit)) + +const updateFilter = (pageVal?: number) => { + if (packagesStore.state.loading) return + + if (pageVal) { + page.value = pageVal + } else { + page.value = 1 + } + + packagesStore.setFilters( + { + exact: exact.value, + status: status.value?.map((state) => state.value), + pkgbase: pkgbase.value !== null ? pkgbase.value : undefined, + repo: repo.value + }, + page.value + ) +} + +const initFilters = () => { + page.value = packagesStore.state.offset / packagesStore.state.limit + 1 + pkgbase.value = packagesStore.state.filters.pkgbase + repo.value = packagesStore.state.filters.repo + exact.value = packagesStore.state.filters.exact + if (packagesStore.state.filters.status) { + for (const state of packagesStore.state.filters.status) { + status.value.push({ title: state.toUpperCase(), value: state.toLowerCase() }) + } + } +} + +watch( + () => page.value, + (pageVal) => { + packagesStore.goToPage(pageVal) + } +) + +// Watcher for pkgbase with debounce +let pkgbaseTimeout: ReturnType | null = null +watch( + () => pkgbase.value, + () => { + if (pkgbaseTimeout) clearTimeout(pkgbaseTimeout) + pkgbaseTimeout = setTimeout(() => { + updateFilter() + }, 300) + } +) + +// Watcher for other filters (repo, status, exact) without debounce +watch( + [() => repo.value, () => status.value?.map((state) => state.value), () => exact.value], + () => { + // Cancel pending pkgbase debounce if any + if (pkgbaseTimeout) { + clearTimeout(pkgbaseTimeout) + pkgbaseTimeout = null + } + updateFilter() + } +) + +onMounted(() => { + initFilters() +}) diff --git a/frontend/src/components/Packages/PackageTable.vue b/frontend/src/components/Packages/PackageTable.vue index eb3b650..c19ebdc 100644 --- a/frontend/src/components/Packages/PackageTable.vue +++ b/frontend/src/components/Packages/PackageTable.vue @@ -31,19 +31,19 @@ diff --git a/frontend/src/composables/Packages/usePackageFilters.ts b/frontend/src/composables/Packages/usePackageFilters.ts deleted file mode 100644 index d8cb7d1..0000000 --- a/frontend/src/composables/Packages/usePackageFilters.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { ref, watch } from 'vue' -import { type Package } from '@/types/Package' -import { useDataStore } from '@/stores/dataStore' - -const OFFSET = 50 - -export interface FilterOptions { - pkgbaseSearch: string - repo?: { title: string; value: string } - statuses: Array<{ title: string; value: string }> - exactSearch: boolean - page: number -} - -export interface FilterResult { - packages: Array - totalPages: number - noResults: boolean -} - -export function usePackageFilters() { - const dataStore = useDataStore() - - const filterOptions = ref({ - pkgbaseSearch: '', - repo: undefined, - statuses: [], - exactSearch: false, - page: 1 - }) - - const filterResult = ref({ - packages: [], - totalPages: 1, - noResults: false - }) - - const applyFilters = () => { - const { pkgbaseSearch, repo, statuses, exactSearch, page } = filterOptions.value - const offset = OFFSET * (page - 1) - - let filteredPackages: Package[] = [] - - if (statuses.length === 1) { - const statusValue = statuses[0].value as Package['status'] - const result = dataStore.getPackagesByStatus(statusValue) - filteredPackages = result.packages - } else { - const allPackagesResult = dataStore.getAllPackages() - filteredPackages = allPackagesResult.packages - - if (statuses.length > 0) { - const statusValues = statuses.map((status) => status.value) - filteredPackages = filteredPackages.filter((pkg) => statusValues.includes(pkg.status)) - } - } - - if (repo) { - filteredPackages = filteredPackages.filter((pkg) => pkg.repo === repo.value) - } - - if (pkgbaseSearch) { - const searchTerm = pkgbaseSearch.toLowerCase() - filteredPackages = filteredPackages.filter((pkg) => - exactSearch - ? pkg.pkgbase.toLowerCase() === searchTerm - : pkg.pkgbase.toLowerCase().includes(searchTerm) - ) - } - - if (filteredPackages.length > 0) { - const startIndex = offset - const endIndex = startIndex + OFFSET - - filterResult.value = { - packages: filteredPackages.slice(startIndex, endIndex), - totalPages: Math.ceil(filteredPackages.length / OFFSET), - noResults: false - } - } else { - filterResult.value = { - packages: [], - totalPages: 1, - noResults: true - } - } - } - - const initFromUrl = () => { - try { - const urlParams = new URLSearchParams(window.location.search) - - const defaultOptions: FilterOptions = { - pkgbaseSearch: '', - page: 1, - exactSearch: false, - statuses: [], - repo: undefined - } - - let page = 1 - if (urlParams.has('page')) { - const pageParam = urlParams.get('page') || '1' - const parsedPage = parseInt(pageParam, 10) - page = !isNaN(parsedPage) && parsedPage > 0 ? parsedPage : 1 - } - - const statuses = urlParams - .getAll('status') - .filter((status) => typeof status === 'string' && status.trim() !== '') - .map((status) => ({ - title: status.toUpperCase(), - value: status - })) - - filterOptions.value = { - ...defaultOptions, - pkgbaseSearch: urlParams.get('pkgbase') || '', - page, - exactSearch: urlParams.has('exact'), - statuses - } - - const repoValue = urlParams.get('repo') - if (repoValue && typeof repoValue === 'string' && repoValue.trim() !== '') { - filterOptions.value.repo = { title: repoValue, value: repoValue } - } - - applyFilters() - - // Check if page exceeds total pages and adjust if necessary - if ( - filterOptions.value.page > filterResult.value.totalPages && - filterResult.value.totalPages > 0 - ) { - filterOptions.value.page = filterResult.value.totalPages - applyFilters() - updateUrlParams() - } - } catch (error) { - console.error('Error parsing URL parameters:', error) - // Reset to default state - filterOptions.value = { - pkgbaseSearch: '', - page: 1, - exactSearch: false, - statuses: [], - repo: undefined - } - applyFilters() - } - } - - const updateUrlParams = () => { - const { pkgbaseSearch, repo, statuses, exactSearch, page } = filterOptions.value - const params = new URLSearchParams() - - // Only add page parameter if it's not the first page - if (page > 1) { - params.set('page', page.toString()) - } - - if (pkgbaseSearch) { - params.set('pkgbase', pkgbaseSearch.toLowerCase()) - } - - if (repo) { - params.set('repo', repo.value) - } - - if (statuses.length > 0) { - statuses.forEach((status) => { - params.append('status', status.value) - }) - } - - if (exactSearch) { - params.set('exact', '') - } - - const paramsString = params.toString() - if (paramsString) { - window.history.pushState(null, '', `${window.location.pathname}?${paramsString}`) - } else { - window.history.pushState(null, '', window.location.pathname) - } - } - - watch( - filterOptions, - () => { - updateUrlParams() - applyFilters() - }, - { deep: true } - ) - - watch( - () => dataStore, - () => { - if (dataStore) { - applyFilters() - } - }, - { deep: true } - ) - - return { - filterOptions, - filterResult, - initFromUrl, - applyFilters - } -} diff --git a/frontend/src/config/constants.ts b/frontend/src/config/constants.ts index 35ee3b6..1c2a25b 100644 --- a/frontend/src/config/constants.ts +++ b/frontend/src/config/constants.ts @@ -4,15 +4,15 @@ export const ROW_HEIGHT = 60 // Select Items Constants export const REPO_ITEMS = [ - { title: 'core-x86-64-v2', value: 'core-x86-64-v2' }, - { title: 'core-x86-64-v3', value: 'core-x86-64-v3' }, - { title: 'core-x86-64-v4', value: 'core-x86-64-v4' }, - { title: 'extra-x86-64-v2', value: 'extra-x86-64-v2' }, - { title: 'extra-x86-64-v3', value: 'extra-x86-64-v3' }, - { title: 'extra-x86-64-v4', value: 'extra-x86-64-v4' }, - { title: 'multilib-x86-64-v2', value: 'multilib-x86-64-v2' }, - { title: 'multilib-x86-64-v3', value: 'multilib-x86-64-v3' }, - { title: 'multilib-x86-64-v4', value: 'multilib-x86-64-v4' } + 'core-x86-64-v2', + 'core-x86-64-v3', + 'core-x86-64-v4', + 'extra-x86-64-v2', + 'extra-x86-64-v3', + 'extra-x86-64-v4', + 'multilib-x86-64-v2', + 'multilib-x86-64-v3', + 'multilib-x86-64-v4' ] export const STATUS_ITEMS = [ diff --git a/frontend/src/generated/alhp.ts b/frontend/src/generated/alhp.ts new file mode 100644 index 0000000..d3e8406 --- /dev/null +++ b/frontend/src/generated/alhp.ts @@ -0,0 +1,273 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/packages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Retrieve package information + * @description Fetch packages from the ALHP system. You can filter results using query parameters. + */ + get: operations["getPackages"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Retrieve general build statistics */ + get: operations["getStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Package: { + /** + * @description The base name of the package + * @example linux-zen + */ + pkgbase?: string; + /** + * @description Repository from which the package originates + * @example extra-x86-64-v4 + */ + repo?: string; + /** + * @description List of package names split from the base package + * @example [ + * "linux-zen", + * "linux-zen-headers", + * "linux-zen-docs" + * ] + */ + split_packages?: string[]; + /** + * @description Current build or repository status of the package + * @example latest + * @enum {string} + */ + status?: "latest" | "failed" | "built" | "skipped" | "delayed" | "building" | "signing" | "unknown" | "queued"; + /** + * @description Reason for skipping the package build (if any) + * @example blacklisted + */ + skip_reason?: string; + /** + * @description Link Time Optimization (LTO) status for the package + * @example enabled + * @enum {string} + */ + lto?: "enabled" | "unknown" | "disabled" | "auto_disabled"; + /** + * @description Availability of debug symbols in the package + * @example available + * @enum {string} + */ + debug_symbols?: "available" | "unknown" | "not_available"; + /** + * @description Version available in the official Arch Linux repositories + * @example 1.3.4-2 + */ + arch_version?: string; + /** + * @description Version available in ALHP repositories (may be empty if not built) + * @example 1.3.4-2.1 + */ + repo_version?: string; + /** + * @description Date and time when the package was built (RFC1123 format) + * @example Fri, 15 Dec 2023 03:43:11 UTC + */ + build_date?: string; + /** + * @description Peak memory usage during the build process (human-readable format) + * @example 5 GB + */ + peak_mem?: string; + }; + /** @description Aggregated statistics across all packages */ + Stats: { + /** + * Format: int64 + * @description Number of packages that failed to build + * @example 17 + */ + failed?: number; + /** + * Format: int64 + * @description Number of packages that were skipped + * @example 29 + */ + skipped?: number; + /** + * Format: int64 + * @description Number of packages that are up-to-date + * @example 743 + */ + latest?: number; + /** + * Format: int64 + * @description Number of packages currently in the build queue + * @example 5 + */ + queued?: number; + /** + * Format: int64 + * @description Number of packages currently building + * @example 11 + */ + building?: number; + /** + * Format: int64 + * @description Latest mirror timestamp to detect outdated mirrors (Unix timestamp) + * @example 1702612991 + */ + last_mirror_timestamp?: number; + /** @description LTO status summary across all packages */ + lto?: { + /** + * Format: int64 + * @description Number of packages with LTO enabled + * @example 532 + */ + enabled?: number; + /** + * Format: int64 + * @description Number of packages with LTO explicitly disabled + * @example 203 + */ + disabled?: number; + /** + * Format: int64 + * @description Number of packages with unknown LTO status + * @example 11 + */ + unknown?: number; + }; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getPackages: { + parameters: { + query: { + /** @description Filter by package status. Accepts multiple values via repeated parameters. */ + status?: ("latest" | "failed" | "built" | "skipped" | "delayed" | "building" | "signing" | "unknown" | "queued")[]; + /** @description Filter by the base package name (`pkgbase`). This is often the main identifier of a package group. */ + pkgbase?: string; + /** @description If present, matches the `pkgbase` exactly. If not provided, allows partial matches. */ + exact?: boolean; + /** @description Filter by repository name. */ + repo?: string; + /** @description Number of results to skip (for pagination). */ + offset: number; + /** @description Maximum number of results to return. */ + limit: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Package data retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + packages?: components["schemas"]["Package"][]; + /** + * Format: int64 + * @description Total number of matching packages + * @example 1423 + */ + total?: number; + /** + * Format: int64 + * @example 0 + */ + offset?: number; + /** + * Format: int64 + * @example 25 + */ + limit?: number; + }; + }; + }; + /** @description No packages found matching the specified filters */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error occurred */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getStats: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description General statistics retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Stats"]; + }; + }; + /** @description Internal server error occurred */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/frontend/src/stores/dataStore.ts b/frontend/src/stores/dataStore.ts deleted file mode 100644 index f30fc79..0000000 --- a/frontend/src/stores/dataStore.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { dataStore } from '@/types/dataStore' -import { apiClient } from '@/utils/fetchUtils' -import { Package } from '@/types/Package' -import { Stats } from '@/types/Stats' - -const UpdateInterval = Number(import.meta.env.VITE_UPDATE_INTERVAL) || 5 - -export const useDataStore = defineStore('data', () => { - const data = ref() - const loading = ref(false) - const error = ref(null) - let refreshInterval: NodeJS.Timeout | null = null - - const init = () => { - data.value = { - lastUpdated: Date.now(), - packages: { - offset: 0, - limit: Number(import.meta.env.VITE_OFFSET) || 50, - totalPackages: [], - byStatus: { - latest: [], - built: [], - failed: [], - skipped: [], - delayed: [], - queued: [], - building: [], - signing: [], - unknown: [] - }, - byRepo: { - core: { - v2: [], - v3: [], - v4: [] - }, - extra: { - v2: [], - v3: [], - v4: [] - }, - multilib: { - v2: [], - v3: [], - v4: [] - } - } - }, - stats: { - total: 0, - built: 0, - building: 0, - latest: 0, - queued: 0, - skipped: 0, - failed: 0, - lto: { - enabled: 0, - disabled: 0, - unknown: 0 - } - } - } - } - - // Fetch all package data from the API - const fetchAllPackageData = async () => { - loading.value = true - error.value = null - try { - const [totalStats, totalPackages] = await Promise.allSettled([ - apiClient.getStats(), - apiClient.getPackages({ limit: 0, offset: 0 }) - ]) - - // Combine into a single data structure - if ( - totalPackages.status === 'fulfilled' && - totalStats.status === 'fulfilled' && - totalPackages.value && - totalStats.value - ) { - // Initialize if needed - if (!data.value) init() - - // Update with the fetched data - data.value!.lastUpdated = Date.now() - data.value!.packages.totalPackages = [...totalPackages.value.packages] - data.value!.packages.offset = totalPackages.value.offset - data.value!.packages.limit = totalPackages.value.limit - - // Update byStatus categorization - data.value!.packages.byStatus = { - latest: totalPackages.value.packages.filter((pkg) => pkg.status === 'latest') || [], - built: totalPackages.value.packages.filter((pkg) => pkg.status === 'built') || [], - failed: totalPackages.value.packages.filter((pkg) => pkg.status === 'failed') || [], - skipped: totalPackages.value.packages.filter((pkg) => pkg.status === 'skipped') || [], - delayed: totalPackages.value.packages.filter((pkg) => pkg.status === 'delayed') || [], - queued: totalPackages.value.packages.filter((pkg) => pkg.status === 'queued') || [], - building: totalPackages.value.packages.filter((pkg) => pkg.status === 'building') || [], - signing: totalPackages.value.packages.filter((pkg) => pkg.status === 'signing') || [], - unknown: totalPackages.value.packages.filter((pkg) => pkg.status === 'unknown') || [] - } - - // Update byRepo categorization - data.value!.packages.byRepo = { - core: { - v2: totalPackages.value.packages.filter((pkg) => pkg.repo === 'core-x86-64-v2') || [], - v3: totalPackages.value.packages.filter((pkg) => pkg.repo === 'core-x86-64-v3') || [], - v4: totalPackages.value.packages.filter((pkg) => pkg.repo === 'core-x86-64-v4') || [] - }, - extra: { - v2: totalPackages.value.packages.filter((pkg) => pkg.repo === 'extra-x86-64-v2') || [], - v3: totalPackages.value.packages.filter((pkg) => pkg.repo === 'extra-x86-64-v3') || [], - v4: totalPackages.value.packages.filter((pkg) => pkg.repo === 'extra-x86-64-v4') || [] - }, - multilib: { - v2: - totalPackages.value.packages.filter((pkg) => pkg.repo === 'multilib-x86-64-v2') || [], - v3: - totalPackages.value.packages.filter((pkg) => pkg.repo === 'multilib-x86-64-v3') || [], - v4: - totalPackages.value.packages.filter((pkg) => pkg.repo === 'multilib-x86-64-v4') || [] - } - } - - // Update stats - data.value!.stats = { - ...totalStats.value, - total: totalPackages.value.total, // Total count from API - built: data.value!.packages.byStatus.built.length, - building: data.value!.packages.byStatus.building.length - } - } - } catch (e) { - console.error('Error fetching package data:', e) - error.value = e as Error - } finally { - loading.value = false - } - } - - // Fetch a specific page of packages - const fetchPackagePage = async (offset: number, limit: number) => { - loading.value = true - error.value = null - try { - const packageData = await apiClient.getPackages({ limit, offset }) - - if (packageData && data.value) { - data.value.packages.totalPackages = [...packageData.packages] - data.value.packages.offset = offset - data.value.packages.limit = limit - - // Update byStatus categorization - data.value.packages.byStatus = { - latest: packageData.packages.filter((pkg) => pkg.status === 'latest') || [], - built: packageData.packages.filter((pkg) => pkg.status === 'built') || [], - failed: packageData.packages.filter((pkg) => pkg.status === 'failed') || [], - skipped: packageData.packages.filter((pkg) => pkg.status === 'skipped') || [], - delayed: packageData.packages.filter((pkg) => pkg.status === 'delayed') || [], - queued: packageData.packages.filter((pkg) => pkg.status === 'queued') || [], - building: packageData.packages.filter((pkg) => pkg.status === 'building') || [], - signing: packageData.packages.filter((pkg) => pkg.status === 'signing') || [], - unknown: packageData.packages.filter((pkg) => pkg.status === 'unknown') || [] - } - - // Update byRepo categorization - data.value.packages.byRepo = { - core: { - v2: packageData.packages.filter((pkg) => pkg.repo === 'core-x86-64-v2') || [], - v3: packageData.packages.filter((pkg) => pkg.repo === 'core-x86-64-v3') || [], - v4: packageData.packages.filter((pkg) => pkg.repo === 'core-x86-64-v4') || [] - }, - extra: { - v2: packageData.packages.filter((pkg) => pkg.repo === 'extra-x86-64-v2') || [], - v3: packageData.packages.filter((pkg) => pkg.repo === 'extra-x86-64-v3') || [], - v4: packageData.packages.filter((pkg) => pkg.repo === 'extra-x86-64-v4') || [] - }, - multilib: { - v2: packageData.packages.filter((pkg) => pkg.repo === 'multilib-x86-64-v2') || [], - v3: packageData.packages.filter((pkg) => pkg.repo === 'multilib-x86-64-v3') || [], - v4: packageData.packages.filter((pkg) => pkg.repo === 'multilib-x86-64-v4') || [] - } - } - } - } catch (e) { - console.error('Error fetching page data:', e) - error.value = e as Error - } finally { - loading.value = false - } - } - - const startAutoRefresh = async () => { - stopAutoRefresh() - - init() - await fetchAllPackageData() - - refreshInterval = setInterval(fetchAllPackageData, UpdateInterval * 60 * 1000) - } - - const stopAutoRefresh = () => { - if (refreshInterval) { - clearInterval(refreshInterval) - refreshInterval = null - } - } - - const getAllPackages = (): { packages: Package[]; total: number } => { - if (!data.value) return { packages: [], total: 0 } - - return { - packages: data.value.packages.totalPackages || [], - total: data.value.stats.total || 0 - } - } - - const getPackagesByStatus = ( - status: Package['status'] - ): { packages: Array; total: number } => { - if (!data.value) return { packages: [], total: 0 } - - return { - packages: data.value.packages.byStatus[status], - total: - status in data.value.stats - ? typeof data.value.stats[status as keyof typeof data.value.stats] === 'number' - ? (data.value.stats[status as keyof typeof data.value.stats] as number) - : 0 - : 0 - } - } - - const getPackageStats = (): - | (Stats & { total: number; built: number; building: number }) - | null => { - return data.value?.stats ?? null - } - - return { - data, - loading, - error, - init, - fetchAllPackageData, - fetchPackagePage, - startAutoRefresh, - stopAutoRefresh, - getAllPackages, - getPackageStats, - getPackagesByStatus - } -}) diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts new file mode 100644 index 0000000..33e595d --- /dev/null +++ b/frontend/src/stores/index.ts @@ -0,0 +1,6 @@ +// Export all stores +export { useStatsStore } from './statsStore' +export { usePackagesStore } from './packagesStore' + +// Export types +export type { PackageFilters, PaginationOptions } from './packagesStore' diff --git a/frontend/src/stores/packagesStore.ts b/frontend/src/stores/packagesStore.ts new file mode 100644 index 0000000..8ab8604 --- /dev/null +++ b/frontend/src/stores/packagesStore.ts @@ -0,0 +1,212 @@ +import { defineStore } from 'pinia' +import { reactive } from 'vue' +import { components, getPackages } from '@/api' + +export interface PackageFilters { + status?: components['schemas']['Package']['status'][] + pkgbase?: components['schemas']['Package']['pkgbase'] + exact?: boolean | undefined + repo?: components['schemas']['Package']['repo'] +} + +export const usePackagesStore = defineStore('packages', () => { + const state = reactive({ + packages: [] as components['schemas']['Package'][], + currentlyBuildingPackages: [] as components['schemas']['Package'][], + total: 0, + offset: 0, + limit: Number(import.meta.env.VITE_LIMIT) || 50, + loading: false, + error: null as string | null, + lastUpdated: Date.now(), + filters: { + status: undefined as components['schemas']['Package']['status'][] | undefined, + pkgbase: undefined as components['schemas']['Package']['pkgbase'] | undefined, + exact: undefined as boolean | undefined, + repo: undefined as components['schemas']['Package']['repo'] | undefined + } as PackageFilters + }) + + // Actions + const fetchPackages = (init = false) => { + state.loading = true + state.error = null + + if (init) { + initFromUrl() + } + + const filter = {} + if (state.filters.status && state.filters.status.length > 0) { + filter['status'] = state.filters.status + } + if (state.filters.pkgbase) { + filter['pkgbase'] = state.filters.pkgbase + } + if (state.filters.exact === true) { + filter['exact'] = '' + } + if (state.filters.repo) { + filter['repo'] = state.filters.repo + } + + getPackages({ + limit: state.limit, + offset: state.offset, + ...filter + }) + .then((response) => { + if (!response) throw new Error('No response from API') + state.packages = response.packages || [] + state.total = response.total || 0 + state.offset = response.offset || 0 + state.limit = response.limit || state.limit + }) + .catch((err) => { + if (err.statusCode === 404) { + state.packages = [] + state.total = 0 + state.offset = 0 + state.limit = Number(import.meta.env.VITE_LIMIT) || 50 + } else { + state.error = err instanceof Error ? err.message : 'Failed to fetch packages' + console.error('Error fetching packages:', err) + } + }) + .finally(() => { + state.loading = false + }) + } + + const fetchCurrentlyBuilding = () => { + getPackages({ + limit: 0, + offset: 0, + status: ['queued', 'building', 'built'] + }) + .then((response) => { + state.currentlyBuildingPackages = response?.packages || [] + }) + .catch((err) => { + if (err.statusCode === 404) { + state.currentlyBuildingPackages = [] + } else { + state.error = + err instanceof Error ? err.message : 'Failed to fetch currently building packages' + console.error('Error fetching queued packages:', err) + } + }) + .finally(() => { + state.loading = false + }) + } + + const goToPage = (page: number) => { + state.offset = (page - 1) * state.limit + + updateUrlParams() + fetchPackages() + } + + const setFilters = (newFilters: PackageFilters, page?: number) => { + state.filters = JSON.parse(JSON.stringify(newFilters)) + if (state.filters.exact === false) { + state.filters.exact = undefined + } + if (page) { + state.offset = (page - 1) * state.limit + } + + updateUrlParams() + fetchPackages() + } + + const resetFilters = () => { + state.filters = { + status: undefined, + pkgbase: undefined, + exact: undefined, + repo: undefined + } + state.offset = 0 + state.limit = Number(import.meta.env.VITE_LIMIT) || 50 + + updateUrlParams() + fetchPackages() + } + + const updateUrlParams = () => { + const params = new URLSearchParams() + + let page = state.offset / state.limit + 1 + // Only add page parameter if it's not the first page + if (page > 1) { + params.set('page', page.toString()) + } + + if (state.filters.status && state.filters.status.length > 0) { + state.filters.status.forEach((status) => { + params.append('status', status) + }) + } + + if (state.filters.pkgbase) { + params.set('pkgbase', state.filters.pkgbase.toLowerCase()) + } + + if (state.filters.repo) { + params.set('repo', state.filters.repo) + } + + if (state.filters.exact === true) { + params.set('exact', '') + } else { + params.delete('exact') + } + + const paramsString = params.toString() + if (paramsString) { + window.history.pushState(null, '', `${window.location.pathname}?${paramsString}`) + } else { + window.history.pushState(null, '', window.location.pathname) + } + } + + const initFromUrl = () => { + const urlParams = new URLSearchParams(window.location.search) + + if (urlParams.has('page')) { + const pageParam = urlParams.get('page') || '1' + const parsedPage = parseInt(pageParam, 10) + const page = !isNaN(parsedPage) && parsedPage > 0 ? parsedPage : 1 + state.offset = (page - 1) * state.limitq + } + + if (urlParams.has('status')) { + state.filters.status = urlParams.getAll('status') + } + + if (urlParams.has('pkgbase')) { + state.filters.pkgbase = urlParams.get('pkgbase') + } + + if (urlParams.has('repo')) { + state.filters.repo = urlParams.get('repo') + } + + if (urlParams.has('exact')) { + state.filters.exact = true + } + } + + return { + state, + + // Actions + fetchPackages, + fetchCurrentlyBuilding, + goToPage, + setFilters, + resetFilters + } +}) diff --git a/frontend/src/stores/statsStore.ts b/frontend/src/stores/statsStore.ts new file mode 100644 index 0000000..89f869a --- /dev/null +++ b/frontend/src/stores/statsStore.ts @@ -0,0 +1,37 @@ +import { defineStore } from 'pinia' +import { reactive } from 'vue' +import { components, getStats } from '@/api' + +export const useStatsStore = defineStore('stats', () => { + const state = reactive({ + stats: {} as components['schemas']['Stats'] | undefined | null, + loading: false, + error: null as string | null, + lastUpdated: Date.now() + }) + + // Actions + const fetchStats = () => { + state.loading = true + state.error = null + + getStats() + .then((response) => { + state.stats = response + }) + .catch((err) => { + state.error = err instanceof Error ? err.message : 'Failed to fetch packages' + console.error('Error fetching packages:', err) + }) + .finally(() => { + state.loading = false + }) + } + + return { + state, + + // Actions + fetchStats + } +}) diff --git a/frontend/src/types/Package.ts b/frontend/src/types/Package.ts deleted file mode 100644 index 7c98ccd..0000000 --- a/frontend/src/types/Package.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface Package { - pkgbase: string - repo: string - split_packages: Array - status: - | 'latest' - | 'failed' - | 'built' - | 'skipped' - | 'delayed' - | 'building' - | 'signing' - | 'unknown' - | 'queued' - skip_reason: string - lto: 'enabled' | 'unknown' | 'disabled' | 'auto_disabled' - debug_symbols: 'available' | 'unknown' | 'not_available' - arch_version: string - repo_version: string - build_date: string - peak_mem: any -} diff --git a/frontend/src/types/Packages.ts b/frontend/src/types/Packages.ts deleted file mode 100644 index 9e0f908..0000000 --- a/frontend/src/types/Packages.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type Package } from '@/types/Package' - -export interface Packages { - packages: Array - total: number - offset: number - limit: number -} diff --git a/frontend/src/types/Stats.ts b/frontend/src/types/Stats.ts deleted file mode 100644 index 0067e46..0000000 --- a/frontend/src/types/Stats.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type Stats_Lto } from '@/types/Stats_Lto' - -export interface Stats { - latest: number - queued: number - skipped: number - failed: number - lto: Stats_Lto -} diff --git a/frontend/src/types/Stats_Lto.ts b/frontend/src/types/Stats_Lto.ts deleted file mode 100644 index 865da2d..0000000 --- a/frontend/src/types/Stats_Lto.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Stats_Lto { - enabled: number - disabled: number - unknown: number -} diff --git a/frontend/src/types/dataStore.ts b/frontend/src/types/dataStore.ts deleted file mode 100644 index f0820e3..0000000 --- a/frontend/src/types/dataStore.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Stats } from '@/types/Stats' -import { Package } from '@/types/Package' - -export interface dataStore { - lastUpdated: number - packages: { - offset: number - limit: number - totalPackages: Array - byStatus: { - latest: Array - built: Array - failed: Array - skipped: Array - delayed: Array - queued: Array - building: Array - signing: Array - unknown: Array - } - byRepo: { - core: { - v2: Array - v3: Array - v4: Array - } - extra: { - v2: Array - v3: Array - v4: Array - } - multilib: { - v2: Array - v3: Array - v4: Array - } - } - } - stats: Stats & { - total: number - built: number - building: number - } -} diff --git a/frontend/src/utils/fetchUtils.ts b/frontend/src/utils/fetchUtils.ts deleted file mode 100644 index 5f1d1aa..0000000 --- a/frontend/src/utils/fetchUtils.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Packages } from '@/types/Packages' -import { Stats } from '@/types/Stats' - -export class ApiClient { - private readonly baseUrl: string - - constructor() { - this.baseUrl = import.meta.env.VITE_BASE_URL - } - - async fetch(endpoint: string, options: RequestInit = {}): Promise { - const url = `${this.baseUrl}${endpoint}` - - try { - const response = await fetch(url, options) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - return await response.json() - } catch (error) { - console.error(`Error fetching from ${url}:`, error) - throw error // Re-throw to allow handling by caller - } - } - - async getPackages( - params: { - limit?: string | number - offset?: string | number - status?: string - } = {} - ): Promise { - const { limit = 0, offset = 0, status } = params - - let endpoint = `/packages?limit=${limit}&offset=${offset}` - if (status) { - endpoint += `&status=${status}` - } - - return this.fetch(endpoint) - } - - async getStats(): Promise { - return this.fetch('/stats') - } -} - -export const apiClient = new ApiClient()