mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
Merge pull request #395 from aneopsy/queue-currently-printing-card
feat(Queue) Add more information in Currently Printing job
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -74,6 +74,7 @@ export default {
|
||||
unknown: '不明',
|
||||
unknownError: '不明なエラー',
|
||||
today: '今日',
|
||||
tomorrow: '明日',
|
||||
asap: '即時',
|
||||
now: '今すぐ',
|
||||
collapse: '折りたたむ',
|
||||
@@ -1856,7 +1857,6 @@ export default {
|
||||
},
|
||||
title: 'ストリームオーバーレイ',
|
||||
progress: '進捗',
|
||||
tomorrow: '明日',
|
||||
printerIdle: 'プリンター待機中',
|
||||
printerOffline: 'プリンターオフライン',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user