mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 12:15:36 +02:00
Two recurring virtual-printer support pains, both on the Virtual Printers
settings page.
Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
checklist — VP enabled, services running, bind interface still exists, access
code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
/ discovery ports on the bind IP. start_server swallows per-service bind
errors, so a service object can exist while nothing is listening; probing the
bind IP from outside is the only reliable signal and it catches the common
"VP not visible in the slicer" bind-IP-conflict and stale-interface cases.
Slicer certificate: virtual printers present a TLS cert signed by a shared CA
the slicer must trust. Until now users had to docker exec in and cat
bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
GET /virtual-printers/ca-certificate returns only the public certificate; the
CA private key never leaves the backend. The CA is generated on demand so the
button works before the first VP is enabled.
Backend:
- services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
- schemas/virtual_printer.py — VPDiagnosticResult
- CertificateService.get_ca_certificate_info() + manager helper
- routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic
Frontend:
- VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
- caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
non-secure-context fallback + downloadTextFile), de-duplicating the
existing FQDN-copy logic
- vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales
9 backend unit tests + 4 route integration tests + 6 frontend tests.
Backend ruff clean, frontend build clean, i18n parity green.
248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info, FileText, ShieldCheck, Copy, Check, Download } from 'lucide-react';
|
|
import { multiVirtualPrinterApi, virtualPrinterApi } from '../api/client';
|
|
import { Card, CardContent } from './Card';
|
|
import { Button } from './Button';
|
|
import { Toggle } from './Toggle';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { copyTextToClipboard, downloadTextFile } from '../utils/clipboard';
|
|
import { VirtualPrinterCard } from './VirtualPrinterCard';
|
|
import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
|
|
|
|
export function VirtualPrinterList() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const { showToast } = useToast();
|
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['virtual-printers'],
|
|
queryFn: multiVirtualPrinterApi.list,
|
|
refetchInterval: 10000,
|
|
});
|
|
|
|
const { data: globalSettings } = useQuery({
|
|
queryKey: ['virtual-printer-settings'],
|
|
queryFn: virtualPrinterApi.getSettings,
|
|
});
|
|
|
|
const archiveNameSourceMutation = useMutation({
|
|
mutationFn: (source: 'metadata' | 'filename') =>
|
|
virtualPrinterApi.updateSettings({ archive_name_source: source }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
|
|
showToast(t('virtualPrinter.toast.updated'));
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
|
|
},
|
|
});
|
|
|
|
const useFilename = globalSettings?.archive_name_source === 'filename';
|
|
|
|
// Shared CA certificate — the slicer imports it once to trust every VP's
|
|
// TLS connection. Generated on demand by the backend, never changes.
|
|
const { data: caCert } = useQuery({
|
|
queryKey: ['vp-ca-certificate'],
|
|
queryFn: multiVirtualPrinterApi.getCaCertificate,
|
|
staleTime: Infinity,
|
|
});
|
|
const [caCopied, setCaCopied] = useState(false);
|
|
|
|
const handleCopyCert = async () => {
|
|
if (!caCert) return;
|
|
const ok = await copyTextToClipboard(caCert.pem);
|
|
if (ok) {
|
|
setCaCopied(true);
|
|
showToast(t('virtualPrinter.caCert.copied'));
|
|
setTimeout(() => setCaCopied(false), 2000);
|
|
} else {
|
|
showToast(t('virtualPrinter.toast.copyFailed'), 'error');
|
|
}
|
|
};
|
|
|
|
const handleDownloadCert = () => {
|
|
if (!caCert) return;
|
|
downloadTextFile(caCert.pem, 'bambuddy-virtual-printer-ca.crt', 'application/x-pem-file');
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-8 flex justify-center">
|
|
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const printers = data?.printers || [];
|
|
const models = data?.models || {};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Top row - Setup Required (1 col) + How it works (2 cols) */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch">
|
|
<Card className="border-l-4 border-l-yellow-500">
|
|
<CardContent className="py-3 px-4">
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs">
|
|
<p className="text-white font-medium">{t('virtualPrinter.setupRequired.title')}</p>
|
|
<p className="text-bambu-gray mt-1">{t('virtualPrinter.setupRequired.description')}</p>
|
|
<a
|
|
href="https://wiki.bambuddy.cool/features/virtual-printer/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1.5 mt-2 px-3 py-1.5 bg-yellow-500/20 border border-yellow-500/50 rounded text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs"
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
{t('virtualPrinter.setupRequired.readGuide')}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="lg:col-span-2">
|
|
<CardContent className="py-3 px-4">
|
|
<div className="flex items-start gap-2">
|
|
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs text-bambu-gray">
|
|
<p className="text-white font-medium mb-1">{t('virtualPrinter.howItWorks.title')}</p>
|
|
<ul className="space-y-1 list-disc list-inside">
|
|
<li>{t('virtualPrinter.howItWorks.step1')}</li>
|
|
<li>{t('virtualPrinter.howItWorks.step2')}</li>
|
|
<li>{t('virtualPrinter.howItWorks.step3')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Global VP behavior settings — two side-by-side cards, not full width */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
|
|
{/* Slicer CA certificate — shared by every VP, imported into the
|
|
slicer's trust store once instead of fetching it from the CLI. */}
|
|
<Card>
|
|
<CardContent className="py-3 px-4">
|
|
<div className="flex items-start gap-3">
|
|
<ShieldCheck className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm text-white font-medium">
|
|
{t('virtualPrinter.caCert.title')}
|
|
</p>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<button
|
|
onClick={handleCopyCert}
|
|
disabled={!caCert}
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded bg-bambu-dark-secondary border border-bambu-dark-tertiary text-white hover:border-bambu-gray disabled:opacity-50 transition-colors"
|
|
>
|
|
{caCopied
|
|
? <Check className="w-3.5 h-3.5 text-bambu-green" />
|
|
: <Copy className="w-3.5 h-3.5" />}
|
|
{caCopied ? t('virtualPrinter.caCert.copied') : t('virtualPrinter.caCert.copy')}
|
|
</button>
|
|
<button
|
|
onClick={handleDownloadCert}
|
|
disabled={!caCert}
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded bg-bambu-dark-secondary border border-bambu-dark-tertiary text-white hover:border-bambu-gray disabled:opacity-50 transition-colors"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
{t('virtualPrinter.caCert.download')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{t('virtualPrinter.caCert.description')}
|
|
</p>
|
|
{caCert && (
|
|
<p
|
|
className="text-[10px] text-bambu-gray font-mono mt-1 truncate"
|
|
title={caCert.fingerprint_sha256}
|
|
>
|
|
{t('virtualPrinter.caCert.fingerprint')}: {caCert.fingerprint_sha256}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Archive name source */}
|
|
<Card>
|
|
<CardContent className="py-3 px-4">
|
|
<div className="flex items-start gap-3">
|
|
<FileText className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm text-white font-medium">
|
|
{t('virtualPrinter.archiveNameSource.title')}
|
|
</p>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<span className={`text-xs ${useFilename ? 'text-bambu-gray' : 'text-white'}`}>
|
|
{t('virtualPrinter.archiveNameSource.metadata')}
|
|
</span>
|
|
<Toggle
|
|
checked={useFilename}
|
|
onChange={(checked) => archiveNameSourceMutation.mutate(checked ? 'filename' : 'metadata')}
|
|
disabled={archiveNameSourceMutation.isPending}
|
|
/>
|
|
<span className={`text-xs ${useFilename ? 'text-white' : 'text-bambu-gray'}`}>
|
|
{t('virtualPrinter.archiveNameSource.filename')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{t('virtualPrinter.archiveNameSource.description')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Header with add button */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Printer className="w-5 h-5 text-bambu-green" />
|
|
<h2 className="text-lg font-semibold text-white">{t('virtualPrinter.list.title')}</h2>
|
|
<span className="text-sm text-bambu-gray">({printers.length})</span>
|
|
</div>
|
|
<Button variant="primary" onClick={() => setShowAddDialog(true)}>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
{t('virtualPrinter.list.add')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Printer cards - 3 column grid */}
|
|
{printers.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="py-8 text-center">
|
|
<Printer className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
|
|
<p className="text-bambu-gray mb-4">{t('virtualPrinter.list.empty')}</p>
|
|
<Button variant="primary" onClick={() => setShowAddDialog(true)}>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
{t('virtualPrinter.list.addFirst')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 items-start">
|
|
{printers.map((printer) => (
|
|
<VirtualPrinterCard key={printer.id} printer={printer} models={models} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showAddDialog && (
|
|
<VirtualPrinterAddDialog onClose={() => setShowAddDialog(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|