Merge pull request #395 from aneopsy/queue-currently-printing-card

feat(Queue) Add more information in Currently Printing job
This commit is contained in:
MartinNYHC
2026-02-16 10:09:15 +01:00
committed by GitHub
13 changed files with 107 additions and 115 deletions
@@ -1,6 +1,6 @@
import { Layers, Check, AlertTriangle } from 'lucide-react';
import { formatTime } from '../../utils/amsHelpers';
import type { PlateSelectorProps } from './types';
import { formatDuration } from '../../utils/date';
/**
* Plate selection grid for multi-plate 3MF files.
@@ -61,7 +61,7 @@ export function PlateSelector({
? plate.objects.slice(0, 3).join(', ') +
(plate.objects.length > 3 ? '...' : '')
: `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
{plate.print_time_seconds != null ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
{plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
</p>
</div>
{selectedPlate === plate.index && (
+1 -1
View File
@@ -86,6 +86,7 @@ export default {
unknown: 'Unbekannt',
unknownError: 'Unbekannter Fehler',
today: 'Heute',
tomorrow: 'Morgen',
asap: 'Sofort',
overdue: 'Überfällig',
now: 'Jetzt',
@@ -1875,7 +1876,6 @@ export default {
cameraStream: 'Kamera-Stream',
progress: 'Fortschritt',
eta: 'ETA',
tomorrow: 'Morgen',
printerIdle: 'Drucker ist inaktiv',
printerOffline: 'Drucker offline',
status: {
+1 -1
View File
@@ -86,6 +86,7 @@ export default {
unknown: 'Unknown',
unknownError: 'Unknown error',
today: 'Today',
tomorrow: 'Tomorrow',
asap: 'ASAP',
overdue: 'Overdue',
now: 'Now',
@@ -1875,7 +1876,6 @@ export default {
cameraStream: 'Camera stream',
progress: 'Progress',
eta: 'ETA',
tomorrow: 'Tomorrow',
printerIdle: 'Printer is idle',
printerOffline: 'Printer offline',
status: {
+1 -1
View File
@@ -86,6 +86,7 @@ export default {
unknown: 'Inconnu',
unknownError: 'Erreur inconnue',
today: 'Aujourd\'hui',
tomorrow: 'Demain',
asap: 'Dès que possible',
overdue: 'En retard',
now: 'Maintenant',
@@ -1871,7 +1872,6 @@ export default {
cameraStream: 'Flux caméra',
progress: 'Progression',
eta: 'Fin estimée',
tomorrow: 'Demain',
printerIdle: 'Imprimante inactive',
printerOffline: 'Imprimante hors ligne',
status: {
+1 -1
View File
@@ -83,6 +83,7 @@ export default {
unknown: 'Sconosciuto',
unknownError: 'Errore sconosciuto',
today: 'Oggi',
tomorrow: 'Domani',
asap: 'ASAP',
overdue: 'Scaduto',
now: 'Ora',
@@ -1688,7 +1689,6 @@ export default {
cameraStream: 'Stream camera',
progress: 'Avanzamento',
eta: 'ETA',
tomorrow: 'Domani',
printerIdle: 'Stampante inattiva',
printerOffline: 'Stampante offline',
status: {
+1 -1
View File
@@ -74,6 +74,7 @@ export default {
unknown: '不明',
unknownError: '不明なエラー',
today: '今日',
tomorrow: '明日',
asap: '即時',
now: '今すぐ',
collapse: '折りたたむ',
@@ -1856,7 +1857,6 @@ export default {
},
title: 'ストリームオーバーレイ',
progress: '進捗',
tomorrow: '明日',
printerIdle: 'プリンター待機中',
printerOffline: 'プリンターオフライン',
},
+1 -8
View File
@@ -50,7 +50,7 @@ import {
} from 'lucide-react';
import { api } from '../api/client';
import { openInSlicer, type SlicerType } from '../utils/slicer';
import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat } from '../utils/date';
import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
import { useIsMobile } from '../hooks/useIsMobile';
import type { Archive, ProjectListItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
@@ -82,13 +82,6 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
/**
* Check if an archive filename represents a sliced/printable file.
* Matches: .gcode, .gcode.3mf, .gcode.anything
+1 -9
View File
@@ -57,6 +57,7 @@ import { ModelViewerModal } from '../components/ModelViewerModal';
import { useToast } from '../contexts/ToastContext';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuth } from '../contexts/AuthContext';
import { formatDuration } from '../utils/date';
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
type SortDirection = 'asc' | 'desc';
@@ -70,15 +71,6 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
// Utility to format duration
function formatDuration(seconds: number | null): string {
if (!seconds) return '-';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
// New Folder Modal
interface NewFolderModalProps {
parentId: number | null;
+4 -40
View File
@@ -46,7 +46,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { api, discoveryApi, firmwareApi } from '../api/client';
import { formatDateOnly } from '../utils/date';
import { formatDateOnly, formatETA, formatDuration } from '../utils/date';
import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
@@ -1087,42 +1087,6 @@ function getSpoolmanFillLevel(
));
}
function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function formatETA(remainingMinutes: number, timeFormat: 'system' | '12h' | '24h' = 'system'): string {
const now = new Date();
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
const today = new Date();
today.setHours(0, 0, 0, 0);
const etaDay = new Date(eta);
etaDay.setHours(0, 0, 0, 0);
// Build time format options based on setting
const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
if (timeFormat === '12h') {
timeOptions.hour12 = true;
} else if (timeFormat === '24h') {
timeOptions.hour12 = false;
}
// 'system' leaves hour12 undefined, letting the browser decide
const timeStr = eta.toLocaleTimeString([], timeOptions);
// Check if it's tomorrow or later
const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (dayDiff === 0) {
return timeStr;
} else if (dayDiff === 1) {
return `Tomorrow ${timeStr}`;
} else {
return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
}
}
function getPrinterImage(model: string | null | undefined): string {
if (!model) return '/img/printers/default.png';
@@ -1348,7 +1312,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
/>
</div>
<span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
<span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
<span className="text-bambu-gray">({formatDuration(nextFinish.remainingMin * 60)})</span>
</div>
</div>
</>
@@ -2455,10 +2419,10 @@ function PrinterCard({
<>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(status.remaining_time * 60)}
{formatDuration(status.remaining_time * 60)}
</span>
<span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
ETA {formatETA(status.remaining_time, timeFormat)}
ETA {formatETA(status.remaining_time, timeFormat, t)}
</span>
</>
)}
+35 -13
View File
@@ -50,7 +50,7 @@ import {
Weight,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
import { parseUTCDate, formatDateTime, type TimeFormat, formatETA, formatDuration } from '../utils/date';
import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
@@ -59,14 +59,6 @@ import { PrintModal } from '../components/PrintModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
function formatDuration(seconds: number | null | undefined): string {
if (!seconds) return '--';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatWeight(g: number, useKg = false): string {
if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
return `${Math.round(g)}g`;
@@ -322,6 +314,12 @@ function SortableQueueItem({
printerState?: string | null;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const { data: status } = useQuery({
queryKey: ['printerStatus', item.printer_id],
queryFn: () => api.getPrinterStatus(item.printer_id!),
refetchInterval: 30000,
enabled: item.printer_id != null && printerState === 'printing',
});
const canReorder = hasPermission('queue:reorder');
const {
attributes,
@@ -498,12 +496,36 @@ function SortableQueueItem({
</div>
{/* Progress bar for printing items - TODO: integrate with WebSocket */}
{isPrinting && (
{isPrinting && status && (
<div className="mt-3">
<div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
<div className="flex items-center justify-between text-sm">
<div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
<div
className="bg-bambu-green h-2 rounded-full transition-all"
style={{ width: `${status.progress || 0}%` }}
/>
</div>
<span className="text-white">{Math.round(status.progress || 0)}%</span>
</div>
<div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
{status.remaining_time != null && status.remaining_time > 0 && (
<>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDuration(status.remaining_time * 60)}
</span>
<span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
ETA {formatETA(status.remaining_time, timeFormat, t)}
</span>
</>
)}
{status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
<span className="flex items-center gap-1">
<Layers className="w-3 h-3" />
{status.layer_num}/{status.total_layers}
</span>
)}
</div>
<p className="text-xs text-bambu-gray mt-1">{t('queue.printingInProgress')}</p>
</div>
)}
+11 -27
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Layers, Clock, Timer, Printer } from 'lucide-react';
import { api } from '../api/client';
import type { PrinterStatus } from '../api/client';
import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
type TFunction = (key: string, options?: Record<string, unknown>) => string;
@@ -46,31 +47,6 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
};
}
function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function formatETA(remainingMinutes: number, t: TFunction): string {
const now = new Date();
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
const today = new Date();
today.setHours(0, 0, 0, 0);
const etaDay = new Date(eta);
etaDay.setHours(0, 0, 0, 0);
const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (etaDay.getTime() === today.getTime()) {
return timeStr;
} else if (etaDay.getTime() === today.getTime() + 86400000) {
return `${t('streamOverlay.tomorrow')} ${timeStr}`;
} else {
return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
}
}
function getStatusText(status: PrinterStatus, t: TFunction): string {
if (status.stg_cur_name) return status.stg_cur_name;
@@ -146,6 +122,14 @@ export function StreamOverlayPage() {
refetchInterval: 2000,
});
// Fetch settings info
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const timeFormat: TimeFormat = settings?.time_format || 'system';
// WebSocket for real-time updates
useEffect(() => {
if (!id) return;
@@ -298,14 +282,14 @@ export function StreamOverlayPage() {
<div className={`flex items-center ${sizes.gap} text-white/70`}>
<Timer className={sizes.icon} />
<span className={`${sizes.text} text-white`}>
{formatTime(status.remaining_time * 60)}
{formatDuration(status.remaining_time * 60)}
</span>
</div>
<div className={`flex items-center ${sizes.gap} text-white/70`}>
<Clock className={sizes.icon} />
<span className={`${sizes.text} text-white`}>
{t('streamOverlay.eta')} {formatETA(status.remaining_time, t)}
{t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
</span>
</div>
</>
-11
View File
@@ -94,17 +94,6 @@ export function getGlobalTrayId(
return amsId * 4 + trayId;
}
/**
* Format seconds to human readable time string.
*/
export function formatTime(seconds: number | null | undefined): string {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
/**
* Get minimum datetime for scheduling (now + 1 minute).
* Returns ISO string format for datetime-local input.
+48
View File
@@ -376,3 +376,51 @@ export function formatTimeOnly(
const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);
return date.toLocaleTimeString([], finalOptions);
}
/**
* Calculate and format an ETA based on remaining minutes from now.
*
* @param remainingMinutes - Minutes until completion
* @param timeFormat - Time format setting ('system', '12h', '24h')
* @param t - Optional i18n translation function
* @returns Formatted ETA string (e.g., "3:45 PM", "Tomorrow 9:30 AM", "Wed 2:00 PM")
*/
export function formatETA(
remainingMinutes: number,
timeFormat: 'system' | '12h' | '24h' = 'system',
t?: (key: string) => string
): string {
const now = new Date();
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
const today = new Date();
today.setHours(0, 0, 0, 0);
const etaDay = new Date(eta);
etaDay.setHours(0, 0, 0, 0);
const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
if (timeFormat === '12h') timeOptions.hour12 = true;
else if (timeFormat === '24h') timeOptions.hour12 = false;
const timeStr = eta.toLocaleTimeString([], timeOptions);
const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / 86400000);
if (dayDiff === 0) return timeStr;
if (dayDiff === 1) return `${t?.('common.tomorrow') ?? 'Tomorrow'} ${timeStr}`;
return `${eta.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`;
}
/**
* Format a duration in seconds to a human-readable string, with null handling.
*
* @param seconds - Duration in seconds, or null/undefined
* @returns Formatted string (e.g., "2h 30m", "45m") or "--" if no value
*/
export function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || seconds < 0) return '--';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}