support states for more types of tasks

This commit is contained in:
Georges-Antoine Assi
2025-10-09 19:20:19 -04:00
parent ff319ff67f
commit ff15cfcee6
21 changed files with 682 additions and 217 deletions

View File

@@ -140,6 +140,9 @@ SCAN_TIMEOUT: Final = int(os.environ.get("SCAN_TIMEOUT", 60 * 60 * 4)) # 4 hour
# TASKS
TASK_TIMEOUT: Final = int(os.environ.get("TASK_TIMEOUT", 60 * 5)) # 5 minutes
TASK_RESULT_TTL: Final = int(
os.environ.get("TASK_RESULT_TTL", 24 * 60 * 60)
) # 24 hours
ENABLE_RESCAN_ON_FILESYSTEM_CHANGE: Final = str_to_bool(
os.environ.get("ENABLE_RESCAN_ON_FILESYSTEM_CHANGE", "false")
)

View File

@@ -13,7 +13,7 @@ class TaskExecutionResponse(TypedDict):
class TaskStatusResponse(TaskExecutionResponse):
started_at: str | None
ended_at: str | None
result: str | None
result: dict[str, Any] | None
meta: dict[str, Any] | None

View File

@@ -8,7 +8,7 @@ import socketio # type: ignore
from rq import Worker, get_current_job
from rq.job import Job
from config import REDIS_URL, SCAN_TIMEOUT
from config import REDIS_URL, SCAN_TIMEOUT, TASK_RESULT_TTL
from endpoints.responses.platform import PlatformSchema
from endpoints.responses.rom import SimpleRomSchema
from exceptions.fs_exceptions import (
@@ -629,6 +629,7 @@ async def scan_handler(_sid: str, options: dict[str, Any]):
roms_ids,
metadata_sources,
job_timeout=SCAN_TIMEOUT, # Timeout (default of 4 hours)
result_ttl=TASK_RESULT_TTL,
)

View File

@@ -8,6 +8,7 @@ from rq.registry import FailedJobRegistry, FinishedJobRegistry
from config import (
ENABLE_RESCAN_ON_FILESYSTEM_CHANGE,
RESCAN_ON_FILESYSTEM_CHANGE_DELAY,
TASK_RESULT_TTL,
TASK_TIMEOUT,
)
from decorators.auth import protected_route
@@ -305,7 +306,12 @@ async def run_all_tasks(request: Request) -> list[TaskExecutionResponse]:
)
jobs = [
(task_name, low_prio_queue.enqueue(task_instance.run, job_timeout=TASK_TIMEOUT))
(
task_name,
low_prio_queue.enqueue(
task_instance.run, job_timeout=TASK_TIMEOUT, result_ttl=TASK_RESULT_TTL
),
)
for task_name, task_instance in runnable_tasks.items()
]
@@ -346,7 +352,9 @@ async def run_single_task(request: Request, task_name: str) -> TaskExecutionResp
detail=f"Task '{task_name}' cannot be run",
)
job = low_prio_queue.enqueue(task_instance.run, job_timeout=TASK_TIMEOUT)
job = low_prio_queue.enqueue(
task_instance.run, job_timeout=TASK_TIMEOUT, result_ttl=TASK_RESULT_TTL
)
return {
"task_name": task_name,

View File

@@ -19,7 +19,7 @@ class CleanupOrphanedResourcesTask(Task):
)
@initialize_context()
async def run(self) -> None:
async def run(self) -> dict[str, int]:
"""Clean up orphaned resources."""
log.info(f"Starting {self.title} task...")
@@ -28,7 +28,7 @@ class CleanupOrphanedResourcesTask(Task):
roms_resources_path = os.path.join(RESOURCES_BASE_PATH, "roms")
if not os.path.exists(roms_resources_path):
log.info("Resources path does not exist, skipping cleanup")
return None
return {"removed_count": 0}
existing_platforms = {
str(platform.id) for platform in db_platform_handler.get_platforms()
@@ -82,5 +82,7 @@ class CleanupOrphanedResourcesTask(Task):
log.info(f"Removed {removed_count} orphaned resource directories")
log.info("Cleanup of orphaned resources completed!")
return {"removed_count": removed_count}
cleanup_orphaned_resources_task = CleanupOrphanedResourcesTask()

View File

@@ -31,11 +31,11 @@ class ScanLibraryTask(PeriodicTask):
func="tasks.scheduled.scan_library.scan_library_task.run",
)
async def run(self):
async def run(self) -> dict[str, str]:
if not ENABLE_SCHEDULED_RESCAN:
log.info("Scheduled library scan not enabled, unscheduling...")
self.unschedule()
return None
return {"status": "skipped", "reason": "Scheduled library scan not enabled"}
source_mapping: dict[str, bool] = {
MetadataSource.IGDB: meta_igdb_handler.is_enabled(),
@@ -54,7 +54,7 @@ class ScanLibraryTask(PeriodicTask):
if not metadata_sources:
log.warning("No metadata sources enabled, unscheduling library scan")
self.unschedule()
return None
return {"status": "skipped", "reason": "No metadata sources enabled"}
log.info("Scheduled library scan started...")
await scan_platforms(
@@ -62,5 +62,7 @@ class ScanLibraryTask(PeriodicTask):
)
log.info("Scheduled library scan done")
return {"status": "completed", "message": "Library scan completed successfully"}
scan_library_task = ScanLibraryTask()

View File

@@ -24,10 +24,14 @@ class SyncRetroAchievementsProgressTask(PeriodicTask):
)
@initialize_context()
async def run(self) -> None:
async def run(self) -> dict[str, str | int]:
if not meta_ra_handler.is_enabled():
log.warning("RetroAchievements API is not enabled, skipping progress sync")
return None
return {
"status": "skipped",
"reason": "RetroAchievements API not enabled",
"updated_users": 0,
}
log.info("Scheduled RetroAchievements progress sync started...")
@@ -57,5 +61,7 @@ class SyncRetroAchievementsProgressTask(PeriodicTask):
f"Scheduled RetroAchievements progress sync done. Updated users: {len(users)}"
)
return {"status": "completed", "updated_users": len(users)}
sync_retroachievements_progress_task = SyncRetroAchievementsProgressTask()

View File

@@ -38,15 +38,15 @@ class UpdateLaunchboxMetadataTask(RemoteFilePullTask):
)
@initialize_context()
async def run(self, force: bool = False) -> None:
async def run(self, force: bool = False) -> dict[str, Any]:
if not meta_launchbox_handler.is_enabled():
log.warning("Launchbox API is not enabled, skipping metadata update")
return None
return {"status": "skipped", "reason": "Launchbox API not enabled"}
content = await super().run(force)
if content is None:
log.warning("No content received from launchbox metadata update")
return None
return {"status": "failed", "reason": "No content received"}
try:
zip_file_bytes = BytesIO(content)
@@ -239,9 +239,13 @@ class UpdateLaunchboxMetadataTask(RemoteFilePullTask):
except zipfile.BadZipFile:
log.error("Bad zip file in launchbox metadata update")
return None
return {"status": "failed", "reason": "Bad zip file"}
log.info("Scheduled launchbox metadata update completed!")
return {
"status": "completed",
"message": "Launchbox metadata update completed successfully",
}
update_launchbox_metadata_task = UpdateLaunchboxMetadataTask()

View File

@@ -28,10 +28,10 @@ class UpdateSwitchTitleDBTask(RemoteFilePullTask):
)
@initialize_context()
async def run(self, force: bool = False) -> None:
async def run(self, force: bool = False) -> dict[str, str]:
content = await super().run(force)
if content is None:
return None
return {"status": "failed", "reason": "No content received"}
index_json = json.loads(content)
relevant_data = {k: v for k, v in index_json.items() if k and v}
@@ -52,5 +52,10 @@ class UpdateSwitchTitleDBTask(RemoteFilePullTask):
log.info("Scheduled switch titledb update completed!")
return {
"status": "completed",
"message": "Switch TitleDB update completed successfully",
}
update_switch_titledb_task = UpdateSwitchTitleDBTask()

View File

@@ -115,7 +115,7 @@ class RemoteFilePullTask(PeriodicTask, ABC):
super().__init__(*args, **kwargs)
self.url = url
async def run(self, force: bool = False) -> bytes | None:
async def run(self, force: bool = False) -> Any:
if not self.enabled and not force:
log.info(f"Scheduled {self.description} not enabled, unscheduling...")
self.unschedule()

View File

@@ -15,6 +15,7 @@ from config import (
RESCAN_ON_FILESYSTEM_CHANGE_DELAY,
SCAN_TIMEOUT,
SENTRY_DSN,
TASK_RESULT_TTL,
)
from config.config_manager import config_manager as cm
from endpoints.sockets.scan import scan_platforms
@@ -144,6 +145,7 @@ def process_changes(changes: Sequence[Change]) -> None:
scan_type=ScanType.UNIDENTIFIED,
metadata_sources=metadata_sources,
timeout=SCAN_TIMEOUT,
result_ttl=TASK_RESULT_TTL,
)
return
@@ -167,6 +169,7 @@ def process_changes(changes: Sequence[Change]) -> None:
scan_type=ScanType.QUICK,
metadata_sources=metadata_sources,
timeout=SCAN_TIMEOUT,
result_ttl=TASK_RESULT_TTL,
)

View File

@@ -79,6 +79,7 @@ RESCAN_ON_FILESYSTEM_CHANGE_DELAY=5
# Tasks (optional)
TASK_TIMEOUT=300
TASK_RESULT_TTL=86400
ENABLE_SCHEDULED_RESCAN=true
SCHEDULED_RESCAN_CRON=0 3 * * *
ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB=true

View File

@@ -1,6 +1,15 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { TaskStatusResponse } from "@/__generated__";
import TaskProgressDisplay from "./tasks/TaskProgressDisplay.vue";
import type {
ScanStats,
ConversionStats,
CleanupStats,
DownloadProgress,
TaskType,
ProgressPercentages,
} from "./tasks/task-types";
const props = defineProps<{
task: TaskStatusResponse;
@@ -34,63 +43,139 @@ const formatDateTime = (dateTime: string | null) => {
};
// Extract scan stats from meta if available
const scanStats = computed(() => {
const scanStats = computed((): ScanStats | null => {
if (props.task.meta?.scan_stats) {
return props.task.meta.scan_stats;
}
return null;
});
// Format scan progress for display
const scanProgress = computed(() => {
if (!scanStats.value) return null;
const stats = scanStats.value;
const totalPlatforms = stats.total_platforms || 0;
const totalRoms = stats.total_roms || 0;
const scannedPlatforms = stats.scanned_platforms || 0;
const scannedRoms = stats.scanned_roms || 0;
return {
platforms: `${scannedPlatforms}/${totalPlatforms}`,
roms: `${scannedRoms}/${totalRoms}`,
addedRoms: stats.added_roms || 0,
metadataRoms: stats.metadata_roms || 0,
scannedFirmware: stats.scanned_firmware || 0,
addedFirmware: stats.added_firmware || 0,
};
// Extract conversion stats from meta if available
const conversionStats = computed((): ConversionStats | null => {
if (props.task.meta?.processed_count !== undefined) {
return {
processed: props.task.meta.processed_count || 0,
errors: props.task.meta.error_count || 0,
total: props.task.meta.total_files || 0,
errorList: props.task.meta.errors || [],
};
}
return null;
});
// Check if this is a scan task
const isScanTask = computed(() => {
return (
props.task.task_name?.toLowerCase().includes("scan") ||
props.task.meta?.scan_stats
);
// Extract cleanup stats from result if available
const cleanupStats = computed((): CleanupStats | null => {
if (props.task.result && typeof props.task.result === "object") {
const result = props.task.result as any;
if (result.removed_count !== undefined) {
return {
removed: result.removed_count || 0,
};
}
}
return null;
});
// Extract download progress from meta if available
const downloadProgress = computed((): DownloadProgress | null => {
if (props.task.meta?.download_progress !== undefined) {
return {
progress: props.task.meta.download_progress || 0,
total: props.task.meta.download_total || 0,
current: props.task.meta.download_current || 0,
};
}
return null;
});
// Check task type
const taskType = computed((): TaskType => {
const taskName = props.task.task_name?.toLowerCase() || "";
if (taskName.includes("scan") || props.task.meta?.scan_stats) {
return "scan";
}
if (
taskName.includes("convert") ||
taskName.includes("webp") ||
conversionStats.value
) {
return "conversion";
}
if (
taskName.includes("cleanup") ||
taskName.includes("orphan") ||
cleanupStats.value
) {
return "cleanup";
}
if (
taskName.includes("update") ||
taskName.includes("metadata") ||
taskName.includes("launchbox") ||
taskName.includes("switch") ||
downloadProgress.value
) {
return "update";
}
if (taskName.includes("watcher") || taskName.includes("filesystem")) {
return "watcher";
}
return "generic";
});
// Expandable details state
const showDetails = ref(false);
// Calculate progress percentages
const progressPercentages = computed(() => {
if (!scanStats.value) return null;
const progressPercentages = computed((): ProgressPercentages | null => {
if (taskType.value === "scan" && scanStats.value) {
const stats = scanStats.value;
const platformProgress =
stats.total_platforms > 0
? Math.round((stats.scanned_platforms / stats.total_platforms) * 100)
: 0;
const romProgress =
stats.total_roms > 0
? Math.round((stats.scanned_roms / stats.total_roms) * 100)
: 0;
const stats = scanStats.value;
const platformProgress =
stats.total_platforms > 0
? Math.round((stats.scanned_platforms / stats.total_platforms) * 100)
: 0;
const romProgress =
stats.total_roms > 0
? Math.round((stats.scanned_roms / stats.total_roms) * 100)
: 0;
return {
platforms: platformProgress,
roms: romProgress,
};
}
return {
platforms: platformProgress,
roms: romProgress,
};
if (taskType.value === "conversion" && conversionStats.value) {
const stats = conversionStats.value;
const total = stats.total || 0;
const processed = stats.processed || 0;
const progress = total > 0 ? Math.round((processed / total) * 100) : 0;
return {
conversion: progress,
};
}
if (taskType.value === "update" && downloadProgress.value) {
const progress = downloadProgress.value;
const downloadProgressPercent =
progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0;
return {
download: downloadProgressPercent,
};
}
return null;
});
const toggleDetails = () => {
showDetails.value = !showDetails.value;
};
</script>
<template>
@@ -114,149 +199,22 @@ const progressPercentages = computed(() => {
Started: {{ formatDateTime(task.started_at) }}
</span>
<!-- Scan Progress Display -->
<div v-if="isScanTask && scanProgress" class="mt-2">
<v-divider class="mb-2" />
<div class="d-flex align-center justify-space-between mb-1">
<div class="text-caption text-blue-grey-lighten-1">
Scan Progress
</div>
<v-btn
v-if="scanStats"
size="x-small"
variant="text"
:icon="showDetails ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="showDetails = !showDetails"
/>
</div>
<!-- Progress Bars -->
<div v-if="progressPercentages" class="mb-3">
<div class="mb-2">
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-caption">Platforms</span>
<span class="text-caption"
>{{ progressPercentages.platforms }}%</span
>
</div>
<v-progress-linear
:model-value="progressPercentages.platforms"
color="primary"
height="6"
rounded
/>
</div>
<div>
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-caption">ROMs</span>
<span class="text-caption"
>{{ progressPercentages.roms }}%</span
>
</div>
<v-progress-linear
:model-value="progressPercentages.roms"
color="secondary"
height="6"
rounded
/>
</div>
</div>
<!-- Summary Chips -->
<div class="d-flex flex-wrap gap-2">
<v-chip size="x-small" color="primary" variant="outlined">
Platforms: {{ scanProgress.platforms }}
</v-chip>
<v-chip size="x-small" color="secondary" variant="outlined">
ROMs: {{ scanProgress.roms }}
</v-chip>
<v-chip size="x-small" color="success" variant="outlined">
Added: {{ scanProgress.addedRoms }}
</v-chip>
<v-chip size="x-small" color="info" variant="outlined">
Metadata: {{ scanProgress.metadataRoms }}
</v-chip>
<v-chip size="x-small" color="warning" variant="outlined">
Firmware: {{ scanProgress.scannedFirmware }}
</v-chip>
</div>
<!-- Detailed Scan Stats -->
<v-expand-transition>
<div v-if="showDetails && scanStats" class="mt-3">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-blue-grey-lighten-1 mb-2">
Detailed Statistics
</div>
<v-row dense>
<v-col cols="6" sm="4">
<div class="text-caption">Total Platforms</div>
<div class="text-h6">
{{ scanStats.total_platforms || 0 }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Scanned Platforms</div>
<div class="text-h6">
{{ scanStats.scanned_platforms || 0 }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">New Platforms</div>
<div class="text-h6">
{{ scanStats.new_platforms || 0 }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Identified Platforms</div>
<div class="text-h6">
{{ scanStats.identified_platforms || 0 }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Total ROMs</div>
<div class="text-h6">
{{ scanStats.total_roms || 0 }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Scanned ROMs</div>
<div class="text-h6">
{{ scanStats.scanned_roms || 0 }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Added ROMs</div>
<div class="text-h6">
{{ scanStats.added_roms || 0 }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Metadata ROMs</div>
<div class="text-h6">
{{ scanStats.metadata_roms || 0 }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Scanned Firmware</div>
<div class="text-h6">
{{ scanStats.scanned_firmware || 0 }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Added Firmware</div>
<div class="text-h6">
{{ scanStats.added_firmware || 0 }}
</div>
</v-col>
</v-row>
</v-card>
</div>
</v-expand-transition>
</div>
<!-- Task Progress Display -->
<TaskProgressDisplay
:task="task"
:task-type="taskType"
:scan-stats="scanStats"
:conversion-stats="conversionStats"
:cleanup-stats="cleanupStats"
:download-progress="downloadProgress"
:progress-percentages="progressPercentages"
:show-details="showDetails"
@toggle-details="toggleDetails"
/>
<!-- Generic Meta Data Display -->
<div
v-else-if="task.meta && Object.keys(task.meta).length > 0"
v-if="task.meta && Object.keys(task.meta).length > 0"
class="mt-2"
>
<v-divider class="mb-2" />

View File

@@ -10,7 +10,6 @@ import { convertCronExperssion } from "@/utils";
const tasksStore = storeTasks();
const { watcherTasks, scheduledTasks, manualTasks, taskStatuses } =
storeToRefs(tasksStore);
const isLoadingRunningTasks = ref(false);
const watcherTasksUI = computed(() =>
watcherTasks.value.map((task) => ({
@@ -103,13 +102,10 @@ const getManualTaskIcon = (taskName: string) => {
// Fetch task status
const fetchTaskStatus = async () => {
isLoadingRunningTasks.value = true;
try {
await tasksStore.fetchTaskStatus();
} catch (error) {
console.error("Error fetching task status:", error);
} finally {
isLoadingRunningTasks.value = false;
}
};
@@ -199,31 +195,18 @@ onUnmounted(() => {
</v-card>
</div>
<v-row no-gutters class="align-center py-1">
<v-col
v-if="taskStatuses.length === 0 && !isLoadingRunningTasks"
cols="12"
>
<v-col v-if="taskStatuses.length === 0" cols="12">
<v-card elevation="0" class="bg-background ma-3">
<v-list-item>
<template #prepend>
<v-icon color="grey">mdi-information-outline</v-icon>
</template>
<v-list-item-title class="text-grey">
No tasks currently running
No currently running tasks
</v-list-item-title>
</v-list-item>
</v-card>
</v-col>
<v-col v-else-if="isLoadingRunningTasks" cols="12">
<v-card elevation="0" class="bg-background ma-3">
<v-list-item>
<template #prepend>
<v-progress-circular indeterminate size="24" />
</template>
<v-list-item-title> Loading running tasks... </v-list-item-title>
</v-list-item>
</v-card>
</v-col>
<v-col
v-else
v-for="task in taskStatuses"

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CleanupStats } from "./task-types";
defineProps<{
cleanupStats: CleanupStats;
}>();
</script>
<template>
<div class="d-flex flex-wrap gap-2">
<v-chip size="x-small" color="success" variant="outlined">
Removed: {{ cleanupStats.removed }} items
</v-chip>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from "vue";
import type {
ConversionStats,
TaskProgress,
ProgressPercentages,
} from "./task-types";
const props = defineProps<{
conversionStats: ConversionStats;
progressPercentages: ProgressPercentages | null;
}>();
const conversionProgress = computed((): TaskProgress => {
const stats = props.conversionStats;
const total = stats.total || 0;
const processed = stats.processed || 0;
const errors = stats.errors || 0;
return {
processed: `${processed}/${total}`,
errors: errors,
successRate:
total > 0 ? Math.round(((processed - errors) / total) * 100) : 0,
};
});
</script>
<template>
<div>
<!-- Progress Bar -->
<div v-if="progressPercentages" class="mb-3">
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-caption">Conversion Progress</span>
<span class="text-caption">{{ progressPercentages.conversion }}%</span>
</div>
<v-progress-linear
:model-value="progressPercentages.conversion"
color="primary"
height="6"
rounded
/>
</div>
<!-- Summary Chips -->
<div class="d-flex flex-wrap gap-2">
<v-chip size="x-small" color="primary" variant="outlined">
Processed: {{ conversionProgress.processed }}
</v-chip>
<v-chip size="x-small" color="error" variant="outlined">
Errors: {{ conversionProgress.errors }}
</v-chip>
<v-chip size="x-small" color="success" variant="outlined">
Success Rate: {{ conversionProgress.successRate }}%
</v-chip>
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from "vue";
import type {
ScanStats,
TaskProgress,
ProgressPercentages,
} from "./task-types";
const props = defineProps<{
scanStats: ScanStats;
progressPercentages: ProgressPercentages | null;
}>();
const scanProgress = computed((): TaskProgress => {
const stats = props.scanStats;
const totalPlatforms = stats.total_platforms || 0;
const totalRoms = stats.total_roms || 0;
const scannedPlatforms = stats.scanned_platforms || 0;
const scannedRoms = stats.scanned_roms || 0;
return {
platforms: `${scannedPlatforms}/${totalPlatforms}`,
roms: `${scannedRoms}/${totalRoms}`,
addedRoms: stats.added_roms || 0,
metadataRoms: stats.metadata_roms || 0,
scannedFirmware: stats.scanned_firmware || 0,
addedFirmware: stats.added_firmware || 0,
};
});
</script>
<template>
<div>
<!-- Progress Bars -->
<div v-if="progressPercentages" class="mb-3">
<div class="mb-2">
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-caption">Platforms</span>
<span class="text-caption">{{ progressPercentages.platforms }}%</span>
</div>
<v-progress-linear
:model-value="progressPercentages.platforms"
color="primary"
height="6"
rounded
/>
</div>
<div>
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-caption">ROMs</span>
<span class="text-caption">{{ progressPercentages.roms }}%</span>
</div>
<v-progress-linear
:model-value="progressPercentages.roms"
color="secondary"
height="6"
rounded
/>
</div>
</div>
<!-- Summary Chips -->
<div class="d-flex flex-wrap gap-2">
<v-chip size="x-small" color="primary" variant="outlined">
Platforms: {{ scanProgress.platforms }}
</v-chip>
<v-chip size="x-small" color="secondary" variant="outlined">
ROMs: {{ scanProgress.roms }}
</v-chip>
<v-chip size="x-small" color="success" variant="outlined">
Added: {{ scanProgress.addedRoms }}
</v-chip>
<v-chip size="x-small" color="info" variant="outlined">
Metadata: {{ scanProgress.metadataRoms }}
</v-chip>
<v-chip size="x-small" color="warning" variant="outlined">
Firmware: {{ scanProgress.scannedFirmware }}
</v-chip>
</div>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import type {
ScanStats,
ConversionStats,
CleanupStats,
DownloadProgress,
TaskType,
} from "./task-types";
const props = defineProps<{
taskType: TaskType;
scanStats?: ScanStats | null;
conversionStats?: ConversionStats | null;
cleanupStats?: CleanupStats | null;
downloadProgress?: DownloadProgress | null;
}>();
</script>
<template>
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-blue-grey-lighten-1 mb-2">
Detailed Statistics
</div>
<!-- Scan Stats Details -->
<v-row v-if="taskType === 'scan' && scanStats" dense>
<v-col cols="6" sm="4">
<div class="text-caption">Total Platforms</div>
<div class="text-h6">{{ scanStats.total_platforms || 0 }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Scanned Platforms</div>
<div class="text-h6">{{ scanStats.scanned_platforms || 0 }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">New Platforms</div>
<div class="text-h6">{{ scanStats.new_platforms || 0 }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Identified Platforms</div>
<div class="text-h6">{{ scanStats.identified_platforms || 0 }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Total ROMs</div>
<div class="text-h6">{{ scanStats.total_roms || 0 }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Scanned ROMs</div>
<div class="text-h6">{{ scanStats.scanned_roms || 0 }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Added ROMs</div>
<div class="text-h6">{{ scanStats.added_roms || 0 }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Metadata ROMs</div>
<div class="text-h6">{{ scanStats.metadata_roms || 0 }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Scanned Firmware</div>
<div class="text-h6">{{ scanStats.scanned_firmware || 0 }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Added Firmware</div>
<div class="text-h6">{{ scanStats.added_firmware || 0 }}</div>
</v-col>
</v-row>
<!-- Conversion Stats Details -->
<v-row v-else-if="taskType === 'conversion' && conversionStats" dense>
<v-col cols="6" sm="4">
<div class="text-caption">Total Files</div>
<div class="text-h6">{{ conversionStats.total }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Processed</div>
<div class="text-h6">{{ conversionStats.processed }}</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Errors</div>
<div class="text-h6">{{ conversionStats.errors }}</div>
</v-col>
<v-col
cols="12"
v-if="conversionStats.errorList && conversionStats.errorList.length > 0"
>
<div class="text-caption">Error Details</div>
<div class="text-caption text-red">
{{ conversionStats.errorList.slice(0, 5).join(", ") }}
<span v-if="conversionStats.errorList.length > 5">...</span>
</div>
</v-col>
</v-row>
<!-- Cleanup Stats Details -->
<v-row v-else-if="taskType === 'cleanup' && cleanupStats" dense>
<v-col cols="6" sm="4">
<div class="text-caption">Removed Items</div>
<div class="text-h6">{{ cleanupStats.removed }}</div>
</v-col>
</v-row>
<!-- Download Stats Details -->
<v-row v-else-if="taskType === 'update' && downloadProgress" dense>
<v-col cols="6" sm="4">
<div class="text-caption">Downloaded</div>
<div class="text-h6">
{{ downloadProgress.current }}/{{ downloadProgress.total }}
</div>
</v-col>
<v-col cols="6" sm="4">
<div class="text-caption">Progress</div>
<div class="text-h6">{{ downloadProgress.progress }}%</div>
</v-col>
</v-row>
</v-card>
</template>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from "vue";
import type { TaskStatusResponse } from "@/__generated__";
import CleanupTaskProgress from "./CleanupTaskProgress.vue";
import ConversionTaskProgress from "./ConversionTaskProgress.vue";
import ScanTaskProgress from "./ScanTaskProgress.vue";
import TaskDetailedStats from "./TaskDetailedStats.vue";
import UpdateTaskProgress from "./UpdateTaskProgress.vue";
import type {
ScanStats,
ConversionStats,
CleanupStats,
DownloadProgress,
TaskType,
ProgressPercentages,
} from "./task-types";
const props = defineProps<{
task: TaskStatusResponse;
taskType: TaskType;
scanStats?: ScanStats | null;
conversionStats?: ConversionStats | null;
cleanupStats?: CleanupStats | null;
downloadProgress?: DownloadProgress | null;
progressPercentages: ProgressPercentages | null;
showDetails: boolean;
}>();
const emit = defineEmits<{
"toggle-details": [];
}>();
const hasDetailedStats = computed(() => {
return !!(
props.scanStats ||
props.conversionStats ||
props.cleanupStats ||
props.downloadProgress
);
});
const progressTitle = computed(() => {
switch (props.taskType) {
case "scan":
return "Scan Progress";
case "conversion":
return "Conversion Progress";
case "cleanup":
return "Cleanup Progress";
case "update":
return "Update Progress";
default:
return "Task Progress";
}
});
</script>
<template>
<div v-if="taskType !== 'generic' && hasDetailedStats" class="mt-2">
<v-divider class="mb-2" />
<div class="d-flex align-center justify-space-between mb-1">
<div class="text-caption text-blue-grey-lighten-1">
{{ progressTitle }}
</div>
<v-btn
v-if="hasDetailedStats"
size="x-small"
variant="text"
:icon="showDetails ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="emit('toggle-details')"
/>
</div>
<!-- Scan Task Progress -->
<ScanTaskProgress
v-if="taskType === 'scan' && scanStats"
:scan-stats="scanStats"
:progress-percentages="progressPercentages"
/>
<!-- Conversion Task Progress -->
<ConversionTaskProgress
v-else-if="taskType === 'conversion' && conversionStats"
:conversion-stats="conversionStats"
:progress-percentages="progressPercentages"
/>
<!-- Cleanup Task Progress -->
<CleanupTaskProgress
v-else-if="taskType === 'cleanup' && cleanupStats"
:cleanup-stats="cleanupStats"
/>
<!-- Update Task Progress -->
<UpdateTaskProgress
v-else-if="taskType === 'update' && downloadProgress"
:download-progress="downloadProgress"
:progress-percentages="progressPercentages"
/>
<!-- Detailed Stats (Expandable) -->
<v-expand-transition>
<div v-if="showDetails && hasDetailedStats" class="mt-3">
<TaskDetailedStats
:task-type="taskType"
:scan-stats="scanStats"
:conversion-stats="conversionStats"
:cleanup-stats="cleanupStats"
:download-progress="downloadProgress"
/>
</div>
</v-expand-transition>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from "vue";
import type {
DownloadProgress,
TaskProgress,
ProgressPercentages,
} from "./task-types";
const props = defineProps<{
downloadProgress: DownloadProgress;
progressPercentages: ProgressPercentages | null;
}>();
const updateProgress = computed((): TaskProgress => {
const progress = props.downloadProgress;
return {
downloaded: `${progress.current}/${progress.total}`,
};
});
</script>
<template>
<div>
<!-- Progress Bar -->
<div v-if="progressPercentages" class="mb-3">
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-caption">Download Progress</span>
<span class="text-caption">{{ progressPercentages.download }}%</span>
</div>
<v-progress-linear
:model-value="progressPercentages.download"
color="primary"
height="6"
rounded
/>
</div>
<!-- Summary Chips -->
<div class="d-flex flex-wrap gap-2">
<v-chip size="x-small" color="primary" variant="outlined">
Downloaded: {{ updateProgress.downloaded }}
</v-chip>
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
// Shared types for task components
export interface ScanStats {
total_platforms: number;
total_roms: number;
scanned_platforms: number;
new_platforms: number;
identified_platforms: number;
scanned_roms: number;
added_roms: number;
metadata_roms: number;
scanned_firmware: number;
added_firmware: number;
}
export interface ConversionStats {
processed: number;
errors: number;
total: number;
errorList: string[];
}
export interface CleanupStats {
removed: number;
}
export interface DownloadProgress {
progress: number;
total: number;
current: number;
}
export interface TaskProgress {
platforms?: string;
roms?: string;
addedRoms?: number;
metadataRoms?: number;
scannedFirmware?: number;
addedFirmware?: number;
processed?: string;
errors?: number;
successRate?: number;
removed?: number;
downloaded?: string;
}
export interface ProgressPercentages {
platforms?: number;
roms?: number;
conversion?: number;
download?: number;
}
export type TaskType =
| "scan"
| "conversion"
| "cleanup"
| "update"
| "watcher"
| "generic";