mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
The printed command only works from the directory holding the compose file, which is the thing the user came to the page not knowing. Adds a copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR, and best-effort detection from a bind mount's host path. Compose records the directory on every container it creates, but reading that label needs the Docker socket mounted in — root-equivalent access for a convenience string. The mountinfo guess is a prefill only: its root field is relative to the mounted device, so a compose dir on its own mount loses that prefix, and nothing in the container can detect it. The field is restricted to path characters. It is the one setting whose purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /" would otherwise render as a plausible update command.
217 lines
6.4 KiB
TypeScript
217 lines
6.4 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { X, Signal, Cable } from 'lucide-react';
|
|
import { Card, CardContent } from './Card';
|
|
import { CopyButton } from './CopyButton';
|
|
import { formatDateOnly } from '../utils/date';
|
|
import { getPrinterImage, getWifiStrength } from '../utils/printer';
|
|
import type { Printer, PrinterStatus } from '../api/client';
|
|
|
|
interface PrinterInfoModalProps {
|
|
printer: Printer;
|
|
status?: PrinterStatus;
|
|
totalPrintHours?: number;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {
|
|
const { t } = useTranslation();
|
|
|
|
useEffect(() => {
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
window.addEventListener('keydown', handleKey);
|
|
return () => window.removeEventListener('keydown', handleKey);
|
|
}, [onClose]);
|
|
|
|
const rows: { label: string; value: React.ReactNode }[] = [];
|
|
|
|
// Model
|
|
rows.push({
|
|
label: t('printers.model'),
|
|
value: printer.model ?? '—',
|
|
});
|
|
|
|
// Connection Status
|
|
rows.push({
|
|
label: t('common.status'),
|
|
value: (
|
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
status?.connected
|
|
? 'bg-bambu-green/20 text-bambu-green'
|
|
: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
|
|
}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${status?.connected ? 'bg-bambu-green' : 'bg-red-400'}`} />
|
|
{status?.connected ? t('printers.status.available') : t('printers.status.offline')}
|
|
</span>
|
|
),
|
|
});
|
|
|
|
// State
|
|
if (status?.state) {
|
|
const stateMap: Record<string, string> = {
|
|
IDLE: 'printers.status.idle',
|
|
RUNNING: 'printers.status.printing',
|
|
PAUSE: 'printers.status.paused',
|
|
FINISH: 'printers.status.finished',
|
|
FAILED: 'printers.status.error',
|
|
};
|
|
rows.push({
|
|
label: t('printers.state'),
|
|
value: t(stateMap[status.state] ?? 'printers.status.unknown'),
|
|
});
|
|
}
|
|
|
|
// IP Address
|
|
rows.push({
|
|
label: t('printers.ipAddress'),
|
|
value: (
|
|
<span className="flex items-center">
|
|
<span className="font-mono">{printer.ip_address}</span>
|
|
<CopyButton value={printer.ip_address} />
|
|
</span>
|
|
),
|
|
});
|
|
|
|
// Serial Number
|
|
rows.push({
|
|
label: t('printers.serialNumber'),
|
|
value: (
|
|
<span className="flex items-center">
|
|
<span className="font-mono truncate">{printer.serial_number}</span>
|
|
<CopyButton value={printer.serial_number} />
|
|
</span>
|
|
),
|
|
});
|
|
|
|
// Network connection
|
|
if (status?.wired_network) {
|
|
rows.push({
|
|
label: t('printers.networkLabel', 'Network'),
|
|
value: (
|
|
<span className="flex items-center gap-2">
|
|
<Cable className="w-4 h-4 text-bambu-green" />
|
|
<span className="text-bambu-green">{t('printers.connection.ethernet', 'Ethernet')}</span>
|
|
</span>
|
|
),
|
|
});
|
|
} else if (status?.wifi_signal != null) {
|
|
const wifi = getWifiStrength(status.wifi_signal);
|
|
rows.push({
|
|
label: t('printers.wifiSignalLabel'),
|
|
value: (
|
|
<span className="flex items-center gap-2">
|
|
<Signal className={`w-4 h-4 ${wifi.color}`} />
|
|
<span className={wifi.color}>{t(wifi.labelKey)}</span>
|
|
<span className="text-bambu-gray text-xs">({status.wifi_signal} dBm)</span>
|
|
</span>
|
|
),
|
|
});
|
|
}
|
|
|
|
// Firmware
|
|
rows.push({
|
|
label: t('printers.firmware'),
|
|
value: status?.firmware_version ?? '—',
|
|
});
|
|
|
|
// Developer Mode
|
|
if (status?.developer_mode != null) {
|
|
rows.push({
|
|
label: t('printers.developerMode'),
|
|
value: (
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
status.developer_mode
|
|
? 'bg-bambu-green/20 text-bambu-green'
|
|
: 'bg-bambu-dark-tertiary text-bambu-gray'
|
|
}`}>
|
|
{status.developer_mode ? t('printers.enabled') : t('printers.disabled')}
|
|
</span>
|
|
),
|
|
});
|
|
}
|
|
|
|
// Nozzle Count
|
|
rows.push({
|
|
label: t('printers.nozzleCount'),
|
|
value: printer.nozzle_count,
|
|
});
|
|
|
|
// Auto-Archive
|
|
rows.push({
|
|
label: t('printers.autoArchive'),
|
|
value: (
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
printer.auto_archive
|
|
? 'bg-bambu-green/20 text-bambu-green'
|
|
: 'bg-bambu-dark-tertiary text-bambu-gray'
|
|
}`}>
|
|
{printer.auto_archive ? t('printers.enabled') : t('printers.disabled')}
|
|
</span>
|
|
),
|
|
});
|
|
|
|
// Total Print Hours
|
|
if (totalPrintHours != null && totalPrintHours > 0) {
|
|
rows.push({
|
|
label: t('printers.totalPrintHours'),
|
|
value: `${Math.round(totalPrintHours)}h`,
|
|
});
|
|
}
|
|
|
|
// Location
|
|
if (printer.location) {
|
|
rows.push({
|
|
label: t('printers.sort.location'),
|
|
value: printer.location,
|
|
});
|
|
}
|
|
|
|
// Added date
|
|
rows.push({
|
|
label: t('printers.addedOn'),
|
|
value: formatDateOnly(printer.created_at),
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
onClick={onClose}
|
|
>
|
|
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold text-white">
|
|
{printer.name}
|
|
</h2>
|
|
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded flex-shrink-0">
|
|
<X className="w-5 h-5 text-bambu-gray" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Printer Image */}
|
|
<div className="flex justify-center mb-4">
|
|
<img
|
|
src={getPrinterImage(printer.model)}
|
|
alt={printer.model ?? printer.name}
|
|
className="h-24 object-contain"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-0">
|
|
{rows.map((row, i) => (
|
|
<div key={i} className="flex items-center justify-between gap-4 py-2.5 border-b border-bambu-dark-tertiary last:border-0">
|
|
<span className="text-sm text-bambu-gray whitespace-nowrap">{row.label}</span>
|
|
<span className="text-sm text-white text-right">{row.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|