Files
bambuddy/frontend/src/components/VirtualPrinterList.tsx
T
maziggy 6bc6a1d683 feat(virtual-printer): setup diagnostic + one-click slicer-certificate export
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.
2026-05-22 13:24:35 +02:00

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>
);
}