immich-automated-selfie-tim.../frontend/src/lib/components/ProgressDisplay.svelte
2026-02-15 20:07:26 +01:00

324 lines
7.8 KiB
Svelte

<script>
import { API } from '../constants.js';
let { jobStatus, progress } = $props();
let percentage = $derived(
progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0
);
let statusLabel = $derived({
idle: 'Idle',
running: 'Processing',
cancelling: 'Cancelling',
compiling_video: 'Compiling Video',
completed: 'Completed',
cancelled: 'Cancelled',
error: 'Error',
}[jobStatus] || jobStatus);
let statusClass = $derived({
idle: '',
running: 'running',
cancelling: 'warning',
compiling_video: 'running',
completed: 'success',
cancelled: 'warning',
error: 'error',
}[jobStatus] || '');
let canCancel = $derived(jobStatus === 'running' || jobStatus === 'compiling_video');
// Person being processed
let personDisplay = $derived(progress.person_name || progress.person_id || null);
// Convert snake_case to readable label (e.g., "face_too_small" -> "Face too small")
function formatLabel(key) {
return key
.split('_')
.map((word, i) => i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word)
.join(' ');
}
// Persist statistics so they remain visible after job completion
let savedSkipStats = $state({});
let savedCompleted = $state(0);
// Skip statistics from the backend - dynamically handle any keys
let skipStats = $derived(progress.skip_stats || {});
// Save statistics whenever they update during processing
$effect(() => {
// Save stats whenever we have valid data (not idle/error)
if (jobStatus !== 'idle' && jobStatus !== 'error') {
// Save skip stats if available
if (Object.keys(skipStats).length > 0) {
savedSkipStats = { ...skipStats };
}
// Save completed count if it's greater than what we have saved
if (progress.completed > savedCompleted) {
savedCompleted = progress.completed;
}
} else if (jobStatus === 'idle' || jobStatus === 'error') {
// Clear saved stats when returning to idle or on error
savedSkipStats = {};
savedCompleted = 0;
}
});
// Use current stats if available, otherwise fall back to saved stats
let displaySkipStats = $derived(
Object.keys(skipStats).length > 0 ? skipStats : savedSkipStats
);
let displayCompleted = $derived(
progress.completed > 0 ? progress.completed : savedCompleted
);
// Use the backend-provided total if available, otherwise calculate from individual counts
// Note: We exclude 'total' from manual calculation to avoid double-counting
let skipTotal = $derived(
typeof displaySkipStats.total === 'number'
? displaySkipStats.total
: Object.entries(displaySkipStats)
.filter(([key, _]) => key !== 'total')
.reduce((sum, [_, val]) => sum + (typeof val === 'number' ? val : 0), 0)
);
// Kept count from backend (tracked atomically alongside skip stats)
let keptCount = $derived(displaySkipStats.kept || 0);
// Dynamically build skip reasons from whatever the backend sends
// Filter to only show non-zero counts, exclude the 'total' field
let skipReasons = $derived(
Object.entries(displaySkipStats)
.filter(([key, count]) => key !== 'total' && key !== 'kept' && typeof count === 'number' && count > 0)
.map(([key, count]) => ({ label: formatLabel(key), count }))
.sort((a, b) => b.count - a.count) // Sort by count descending
);
async function cancelProcessing() {
try {
await fetch(API.cancel, { method: 'POST' });
} catch (e) {
console.error('Cancel failed:', e);
}
}
</script>
<div class="progress-display">
<div class="header">
<div class="header-left">
<span class="status {statusClass}">{statusLabel}</span>
{#if personDisplay}
<span class="person-name">{personDisplay}</span>
{/if}
</div>
<div class="header-right">
{#if progress.total > 0 && (jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling')}
<span class="count">{progress.completed} / {progress.total}</span>
{/if}
{#if canCancel}
<button type="button" class="cancel-btn" onclick={cancelProcessing}>Cancel</button>
{/if}
</div>
</div>
{#if jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'}
<div class="progress-bar">
<div class="progress-fill" style="width: {percentage}%"></div>
</div>
{/if}
{#if progress.message}
<p class="message">{@html progress.message}</p>
{/if}
{#if (jobStatus === 'running' || jobStatus === 'completed' || jobStatus === 'cancelled') && (displayCompleted > 0 || Object.keys(displaySkipStats).length > 0)}
<div class="skip-stats">
<div class="skip-header">
<span class="skip-label">Discarded : Kept</span>
<span class="skip-totals">
<span class="skipped-count">{skipTotal}</span>
<span class="separator">:</span>
<span class="kept-count">{keptCount}</span>
</span>
</div>
<ul class="skip-reasons">
{#each skipReasons as reason}
<li class="skip-reason">
<span class="reason-label">{reason.label}</span>
<span class="reason-count">{reason.count}</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>
<style>
.progress-display {
background: #1a1a1a;
border-radius: 8px;
padding: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.person-name {
font-size: 0.875rem;
color: #e0e0e0;
font-weight: 500;
}
.status {
font-weight: 600;
font-size: 0.875rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
background: #333;
}
.status.running {
background: #1e3a5f;
color: #60a5fa;
}
.status.success {
background: #14532d;
color: #4ade80;
}
.status.warning {
background: #422006;
color: #fbbf24;
}
.status.error {
background: #450a0a;
color: #f87171;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.count {
font-size: 0.875rem;
color: #888;
}
.cancel-btn {
padding: 0.375rem 0.75rem;
background: #dc2626;
border: none;
border-radius: 4px;
color: #fff;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.cancel-btn:hover {
background: #b91c1c;
}
.progress-bar {
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
margin-bottom: 1rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
transition: width 0.3s ease;
}
.message {
font-size: 0.875rem;
color: #888;
}
/* Skip statistics */
.skip-stats {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #252525;
}
.skip-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.skip-label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.skip-totals {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
}
.skipped-count {
color: #f59e0b;
}
.separator {
color: #666;
}
.kept-count {
color: #22c55e;
}
.skip-reasons {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.25rem;
}
.skip-reason {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.5rem;
background: #252525;
border-radius: 4px;
font-size: 0.75rem;
}
.reason-label {
color: #888;
}
.reason-count {
color: #e0e0e0;
font-weight: 500;
}
</style>