mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Spoolman rejects a spool whose extra dict carries a key it has not been told about, answering 400 "Unknown extra field tag.". Bambuddy keeps the tray UUID in extra.tag, so that key has to exist before the first spool is created. Registration ran from three hand-maintained lists that fire when the integration is set up -- the connect route, startup, and two inline blocks in the inventory routes. Enabling Spoolman from the Settings page reaches none of them, so the first AMS sync on a fresh Spoolman failed on every slot while vendor and filament creation succeeded. Neither fix suggested on the issue is quite the right shape. Adding the block to PUT /settings/spoolman fixes this path and makes a third copy of a list that has already drifted -- it would still omit bambu_color_name. Ensuring at the sync entry point leaves the other four tag writers alone: linking and unlinking a tag, and both inventory edit paths. So the registration moved to the write. create_spool, update_spool and update_spool_full each register the keys of the extra dict they are about to send, once per client, before sending. Every tag writer funnels through one of the three, merge_spool_extra included. This closes the class rather than the instance: a write that carries a key is a write that registers it, and bambu_color_name shows why that matters -- it never made it into the connect or startup lists at all, and works today only because two call sites remembered it by hand. Best-effort, deliberately. ensure_extra_field already logs and returns False rather than raising, so a registration that fails leaves the write to be attempted and to report exactly what it reported before. Failures are not memoised either, so a Spoolman that was merely restarting gets another try on the next write. The older blocks stay. They are redundant now, but the inventory routes' inline calls are pinned by tests that assert them against a mocked client, where the funnel cannot run. The status endpoint is the other half. The Connect button would have registered the fields, and the reason nobody reaches it is that GET /spoolman/status reported "connected" whenever an earlier request had left a client object behind. Roughly twenty call sites build one lazily, and saving the Settings page builds one as a side effect of syncing locations, so the flag turned on which page had been opened rather than on anything about Spoolman. The UI reads it twice -- Connect only while disconnected, the sync section only while connected -- so those two controls landed in states the user cannot explain. It now asks the Spoolman that is configured, including the stale-URL check every other route already does, and does not probe at all when the integration is switched off, which used to let a leftover client report a disabled Spoolman as connected. That leaves nothing for Disconnect to do, so it is gone. Spoolman is a stateless HTTP API with no session to close; the button dropped the client object, the next request rebuilt it lazily, and the status flipped back on its own within the 30s poll -- an action that looked like it worked and then quietly undid itself. The enable toggle owns turning the integration off. Connect stays as what it always was in practice, a way to re-check a Spoolman that is not answering, and is shown only then. Its translation keys are left in place; only the control is removed. Resolving the client there means the status poll can now fail in ways a read-only check could not, so it no longer reports failure by failing. Replacing a client closes the previous one and httpx's aclose() is not guaranteed not to raise; a poll that runs every 30 seconds answering 500 is worse than one answering what is true either way, which is that Spoolman could not be reached. The SSRF rejection keeps its own message rather than folding into the general one -- a URL the guard refuses is the admin's to correct, and that is only actionable if the log says so. Tests drive the real client against a fake Spoolman that enforces the unknown-extra-field rule rather than mocking it away, so each one fails against the old code for the reason the reporter's install did. The concurrency test needed the fake to be async: MockTransport answers without suspending, so the first version ran each request to completion in turn and passed just as happily with the lock removed.
656 lines
30 KiB
TypeScript
656 lines
30 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Loader2, Check, X, RefreshCw, Link2, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink } from 'lucide-react';
|
|
import { api, ApiError } from '../api/client';
|
|
import type { SpoolmanSyncResult, Printer } from '../api/client';
|
|
import { Card, CardContent, CardHeader } from './Card';
|
|
import { Button } from './Button';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
|
|
export function SpoolmanSettings() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const { showToast } = useToast();
|
|
const [localEnabled, setLocalEnabled] = useState(false);
|
|
const [localUrl, setLocalUrl] = useState('');
|
|
const [localSyncMode, setLocalSyncMode] = useState('auto');
|
|
const [localDisableWeightSync, setLocalDisableWeightSync] = useState(false);
|
|
const [localReportPartialUsage, setLocalReportPartialUsage] = useState(true);
|
|
const [localAutoAddUnknownRfid, setLocalAutoAddUnknownRfid] = useState(true);
|
|
const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
|
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
const [showAllSkipped, setShowAllSkipped] = useState(false);
|
|
const [showAmsSyncConfirm, setShowAmsSyncConfirm] = useState(false);
|
|
const [showSpoolmanAmsSyncConfirm, setShowSpoolmanAmsSyncConfirm] = useState(false);
|
|
|
|
// Fetch Spoolman settings
|
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
|
queryKey: ['spoolman-settings'],
|
|
queryFn: api.getSpoolmanSettings,
|
|
});
|
|
|
|
// Fetch Spoolman status
|
|
const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
|
|
queryKey: ['spoolman-status'],
|
|
queryFn: api.getSpoolmanStatus,
|
|
refetchInterval: 30000, // Refresh every 30 seconds
|
|
});
|
|
|
|
// Fetch printers for the dropdown
|
|
const { data: printers } = useQuery({
|
|
queryKey: ['printers'],
|
|
queryFn: api.getPrinters,
|
|
});
|
|
|
|
// Initialize local state from settings
|
|
useEffect(() => {
|
|
if (settings) {
|
|
setLocalEnabled(settings.spoolman_enabled === 'true');
|
|
setLocalUrl(settings.spoolman_url || '');
|
|
setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
|
|
setLocalDisableWeightSync(settings.spoolman_disable_weight_sync === 'true');
|
|
setLocalReportPartialUsage(settings.spoolman_report_partial_usage !== 'false');
|
|
setLocalAutoAddUnknownRfid(settings.auto_add_unknown_rfid !== 'false');
|
|
setIsInitialized(true);
|
|
}
|
|
}, [settings]);
|
|
|
|
// Auto-save when settings change (after initial load)
|
|
// Intentionally omit saveMutation and settings from deps to avoid infinite loops
|
|
useEffect(() => {
|
|
if (!isInitialized || !settings) return;
|
|
|
|
const hasChanges =
|
|
(settings.spoolman_enabled === 'true') !== localEnabled ||
|
|
(settings.spoolman_url || '') !== localUrl ||
|
|
(settings.spoolman_sync_mode || 'auto') !== localSyncMode ||
|
|
(settings.spoolman_disable_weight_sync === 'true') !== localDisableWeightSync ||
|
|
(settings.spoolman_report_partial_usage !== 'false') !== localReportPartialUsage ||
|
|
(settings.auto_add_unknown_rfid !== 'false') !== localAutoAddUnknownRfid;
|
|
|
|
if (hasChanges) {
|
|
const timeoutId = setTimeout(() => {
|
|
saveMutation.mutate();
|
|
}, 500);
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, localAutoAddUnknownRfid, isInitialized]);
|
|
|
|
// Save mutation
|
|
const saveMutation = useMutation({
|
|
mutationFn: () =>
|
|
api.updateSpoolmanSettings({
|
|
spoolman_enabled: localEnabled ? 'true' : 'false',
|
|
spoolman_url: localUrl,
|
|
spoolman_sync_mode: localSyncMode,
|
|
spoolman_disable_weight_sync: localDisableWeightSync ? 'true' : 'false',
|
|
spoolman_report_partial_usage: localReportPartialUsage ? 'true' : 'false',
|
|
auto_add_unknown_rfid: localAutoAddUnknownRfid ? 'true' : 'false',
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
|
|
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
|
|
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
|
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
|
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-filaments'] });
|
|
showToast(t('settings.toast.settingsSaved'));
|
|
},
|
|
onError: () => {
|
|
showToast(t('settings.toast.saveFailed'), 'error');
|
|
},
|
|
});
|
|
|
|
// Connect mutation
|
|
const connectMutation = useMutation({
|
|
mutationFn: api.connectSpoolman,
|
|
onSuccess: () => {
|
|
refetchStatus();
|
|
},
|
|
onError: () => {
|
|
showToast(t('settings.toast.saveFailed'), 'error');
|
|
},
|
|
});
|
|
|
|
// Sync all mutation
|
|
const syncAllMutation = useMutation({
|
|
mutationFn: api.syncAllPrintersAms,
|
|
onSuccess: (data: SpoolmanSyncResult) => {
|
|
showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced_count, skipped: 0 }), 'success');
|
|
},
|
|
onError: () => {
|
|
showToast(t('settings.toast.saveFailed'), 'error');
|
|
},
|
|
});
|
|
|
|
// Sync single printer mutation
|
|
const syncPrinterMutation = useMutation({
|
|
mutationFn: (printerId: number) => api.syncPrinterAms(printerId),
|
|
onSuccess: (data: SpoolmanSyncResult) => {
|
|
showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced_count, skipped: 0 }), 'success');
|
|
},
|
|
onError: () => {
|
|
showToast(t('settings.toast.saveFailed'), 'error');
|
|
},
|
|
});
|
|
|
|
// Helper to handle sync based on selection
|
|
const handleSync = () => {
|
|
if (selectedPrinterId === 'all') {
|
|
syncAllMutation.mutate();
|
|
} else {
|
|
syncPrinterMutation.mutate(selectedPrinterId);
|
|
}
|
|
};
|
|
|
|
// Inventory AMS weight sync mutation
|
|
const amsSyncMutation = useMutation({
|
|
mutationFn: api.syncWeightsFromAms,
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['spools'] });
|
|
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
|
showToast(t('settings.amsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success');
|
|
setShowAmsSyncConfirm(false);
|
|
},
|
|
onError: () => {
|
|
showToast(t('settings.amsSyncError'), 'error');
|
|
setShowAmsSyncConfirm(false);
|
|
},
|
|
});
|
|
|
|
// Spoolman AMS weight sync mutation
|
|
const spoolmanAmsSyncMutation = useMutation({
|
|
mutationFn: api.syncSpoolmanAmsWeights,
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
|
|
showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success');
|
|
setShowSpoolmanAmsSyncConfirm(false);
|
|
},
|
|
onError: (error: Error) => {
|
|
if (error instanceof ApiError && error.status === 503) {
|
|
showToast(t('settings.spoolmanAmsSyncErrorUnreachable'), 'error');
|
|
} else if (error instanceof ApiError && error.status === 400) {
|
|
showToast(t('settings.spoolmanAmsSyncErrorNotConfigured'), 'error');
|
|
} else {
|
|
showToast(t('settings.spoolmanAmsSyncError'), 'error');
|
|
}
|
|
setShowSpoolmanAmsSyncConfirm(false);
|
|
},
|
|
});
|
|
|
|
// Combine mutation states
|
|
const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
|
|
const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
|
|
const syncSuccess = selectedPrinterId === 'all' ? syncAllMutation.isSuccess : syncPrinterMutation.isSuccess;
|
|
|
|
if (settingsLoading) {
|
|
return (
|
|
<Card id="card-spoolman">
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Database className="w-5 h-5 text-bambu-green" />
|
|
<h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card id="card-spoolman">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="w-5 h-5 text-bambu-green" />
|
|
<h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
|
|
</div>
|
|
{saveMutation.isPending && (
|
|
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('settings.filamentTrackingDesc')}
|
|
</p>
|
|
|
|
{/* Mode selector cards */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{/* Built-in Inventory */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setLocalEnabled(false)}
|
|
className={`p-3 rounded-lg border-2 text-left transition-colors ${
|
|
!localEnabled
|
|
? 'border-bambu-green bg-bambu-green/10'
|
|
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<Package className={`w-4 h-4 ${!localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
|
<span className={`text-sm font-medium ${!localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
|
|
{t('settings.trackingModeBuiltIn')}
|
|
</span>
|
|
</div>
|
|
<p className={`text-xs ${!localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
|
|
{t('settings.trackingModeBuiltInDesc')}
|
|
</p>
|
|
{!localEnabled && (
|
|
<div className="flex items-center gap-1 mt-2">
|
|
<Check className="w-3 h-3 text-bambu-green" />
|
|
<span className="text-xs text-bambu-green">{t('common.enabled')}</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
|
|
{/* Spoolman */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setLocalEnabled(true)}
|
|
className={`p-3 rounded-lg border-2 text-left transition-colors ${
|
|
localEnabled
|
|
? 'border-bambu-green bg-bambu-green/10'
|
|
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<ExternalLink className={`w-4 h-4 ${localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
|
<span className={`text-sm font-medium ${localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
|
|
Spoolman
|
|
</span>
|
|
</div>
|
|
<p className={`text-xs ${localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
|
|
{t('settings.trackingModeSpoolmanDesc')}
|
|
</p>
|
|
{localEnabled && (
|
|
<div className="flex items-center gap-1 mt-2">
|
|
<Check className="w-3 h-3 text-bambu-green" />
|
|
<span className="text-xs text-bambu-green">{t('common.enabled')}</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Auto-add unknown RFID toggle — applies to both internal and Spoolman modes */}
|
|
<div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary">
|
|
<div className="pr-4">
|
|
<p className="text-white">{t('settings.autoAddUnknownRfid')}</p>
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('settings.autoAddUnknownRfidDesc')}
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={localAutoAddUnknownRfid}
|
|
onChange={(e) => setLocalAutoAddUnknownRfid(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Built-in Inventory details */}
|
|
{!localEnabled && (
|
|
<div className="space-y-3">
|
|
<div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
|
|
<div className="flex gap-2">
|
|
<Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs text-bambu-gray">
|
|
<ul className="list-disc list-inside space-y-0.5">
|
|
<li>{t('settings.builtInFeatureRfid')}</li>
|
|
<li>{t('settings.builtInFeatureUsage')}</li>
|
|
<li>{t('settings.builtInFeatureCatalog')}</li>
|
|
<li>{t('settings.builtInFeatureThirdParty')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowAmsSyncConfirm(true)}
|
|
disabled={amsSyncMutation.isPending}
|
|
>
|
|
{amsSyncMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
)}
|
|
{t('settings.amsSyncButton')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Spoolman settings - only shown when Spoolman mode is selected */}
|
|
{localEnabled && (
|
|
<div className="space-y-4">
|
|
{/* Info banner about sync requirements */}
|
|
<div className="p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-300 dark:border-blue-500/30 rounded-lg">
|
|
<div className="flex gap-2">
|
|
<Info className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
|
<div className="text-xs text-blue-700 dark:text-blue-300">
|
|
<p className="font-medium mb-1">{t('settings.howSyncWorks')}</p>
|
|
<ul className="list-disc list-inside space-y-0.5 text-blue-700/90 dark:text-blue-300/80">
|
|
<li>{t('settings.syncInfoRfidOnly')}</li>
|
|
<li>{t('settings.syncInfoAutoCreate')}</li>
|
|
<li>{t('settings.syncInfoThirdPartySkipped')}</li>
|
|
</ul>
|
|
<p className="font-medium mt-2 mb-1">{t('settings.linkingExistingSpools')}</p>
|
|
<p className="text-blue-700/90 dark:text-blue-300/80">
|
|
{t('settings.linkingExistingSpoolsDesc')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* URL input */}
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">
|
|
{t('settings.spoolmanUrl')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="http://192.168.1.100:7912"
|
|
value={localUrl}
|
|
onChange={(e) => setLocalUrl(e.target.value)}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none"
|
|
/>
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{t('settings.spoolmanUrlHint')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Sync mode */}
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">
|
|
{t('settings.syncMode')}
|
|
</label>
|
|
<select
|
|
value={localSyncMode}
|
|
onChange={(e) => setLocalSyncMode(e.target.value)}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
|
>
|
|
<option value="auto">{t('settings.syncModeAuto')}</option>
|
|
<option value="manual">{t('settings.syncModeManual')}</option>
|
|
</select>
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
{localSyncMode === 'auto'
|
|
? t('settings.syncModeAutoDesc')
|
|
: t('settings.syncModeManualDesc')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Disable Weight Sync toggle - only show when sync mode is auto */}
|
|
{localSyncMode === 'auto' && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-white">{t('spoolman.disableWeightSync')}</p>
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('spoolman.disableWeightSyncDesc')}
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={localDisableWeightSync}
|
|
onChange={(e) => setLocalDisableWeightSync(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* Report Partial Usage toggle - only show when weight sync is disabled */}
|
|
{localDisableWeightSync && (
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-white">{t('spoolman.reportPartialUsage')}</p>
|
|
<p className="text-sm text-bambu-gray">
|
|
{t('spoolman.reportPartialUsageDesc')}
|
|
</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={localReportPartialUsage}
|
|
onChange={(e) => setLocalReportPartialUsage(e.target.checked)}
|
|
className="sr-only peer"
|
|
/>
|
|
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connection status */}
|
|
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-bambu-gray">{t('settings.status')}:</span>
|
|
{statusLoading ? (
|
|
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
|
|
) : status?.connected ? (
|
|
<span className="flex items-center gap-1 text-sm text-green-500">
|
|
<Check className="w-4 h-4" />
|
|
{t('settings.spoolmanConnected')}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1 text-sm text-red-500">
|
|
<X className="w-4 h-4" />
|
|
{t('settings.spoolmanDisconnected')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Retry affordance only. Spoolman is a stateless HTTP API with
|
|
no session to hold open, so there is nothing for a Disconnect
|
|
button to disconnect: it closed this process's client object,
|
|
which any other request rebuilt lazily moments later, and the
|
|
status above silently flipped back. The enable toggle is what
|
|
turns the integration off. */}
|
|
<div className="flex gap-2">
|
|
{!status?.connected && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => connectMutation.mutate()}
|
|
disabled={connectMutation.isPending || !localUrl}
|
|
>
|
|
{connectMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Link2 className="w-4 h-4" />
|
|
)}
|
|
{t('settings.connect')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error display */}
|
|
{connectMutation.isError && (
|
|
<div className="mb-3 p-2 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded text-sm text-red-700 dark:text-red-400">
|
|
{(connectMutation.error as Error).message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Manual sync section */}
|
|
{status?.connected && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-sm text-white">{t('settings.syncAmsData')}</p>
|
|
<p className="text-xs text-bambu-gray">
|
|
{t('settings.syncAmsDataDesc')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Printer selector */}
|
|
<div className="relative flex-1">
|
|
<select
|
|
value={selectedPrinterId}
|
|
onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
|
className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
|
|
>
|
|
<option value="all">{t('settings.allPrinters')}</option>
|
|
{printers?.map((printer: Printer) => (
|
|
<option key={printer.id} value={printer.id}>
|
|
{printer.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
|
</div>
|
|
{/* Sync button */}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={handleSync}
|
|
disabled={isSyncing}
|
|
>
|
|
{isSyncing ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4" />
|
|
)}
|
|
{t('spoolman.sync')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Spoolman AMS weight sync */}
|
|
{status?.connected && (
|
|
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-white">{t('settings.spoolmanAmsSyncButton')}</p>
|
|
<p className="text-xs text-bambu-gray">{t('settings.spoolmanAmsSyncMessage')}</p>
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowSpoolmanAmsSyncConfirm(true)}
|
|
disabled={spoolmanAmsSyncMutation.isPending}
|
|
>
|
|
{spoolmanAmsSyncMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4" />
|
|
)}
|
|
{spoolmanAmsSyncMutation.isPending ? t('settings.spoolmanAmsSyncing') : t('settings.spoolmanAmsSyncButton')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sync result */}
|
|
{syncSuccess && syncResult && (
|
|
<div className="mt-3 space-y-2">
|
|
{/* Main result */}
|
|
<div
|
|
className={`p-2 rounded text-sm ${
|
|
syncResult.success
|
|
? 'bg-green-100 dark:bg-green-500/20 border border-green-300 dark:border-green-500/50 text-green-700 dark:text-green-400'
|
|
: 'bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-300 dark:border-yellow-500/50 text-yellow-700 dark:text-yellow-400'
|
|
}`}
|
|
>
|
|
{syncResult.success
|
|
? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`
|
|
: `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
|
|
</div>
|
|
|
|
{/* Skipped spools */}
|
|
{syncResult.skipped_count > 0 && (
|
|
<div className="p-2 bg-amber-50 dark:bg-amber-500/10 border border-amber-300 dark:border-amber-500/30 rounded text-sm">
|
|
<div className="flex items-center justify-between text-amber-700 dark:text-amber-400 mb-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<AlertTriangle className="w-3.5 h-3.5" />
|
|
<span className="font-medium">
|
|
{syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
|
|
</span>
|
|
</div>
|
|
{syncResult.skipped_count > 5 && (
|
|
<button
|
|
onClick={() => setShowAllSkipped(!showAllSkipped)}
|
|
className="text-xs text-amber-700 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 underline"
|
|
>
|
|
{showAllSkipped ? 'Show less' : 'Show all'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<ul className="text-xs text-amber-700/90 dark:text-amber-300/80 space-y-0.5">
|
|
{(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
|
|
<li key={i} className="flex items-center gap-2">
|
|
{s.color && (
|
|
<span
|
|
className="w-3 h-3 rounded-full border border-black/20"
|
|
style={{ backgroundColor: `#${s.color}` }}
|
|
/>
|
|
)}
|
|
<span>{s.location}</span>
|
|
<span className="text-amber-700/70 dark:text-amber-300/60">- {s.reason}</span>
|
|
</li>
|
|
))}
|
|
{!showAllSkipped && syncResult.skipped_count > 5 && (
|
|
<li className="text-amber-700/70 dark:text-amber-300/60 italic">
|
|
...and {syncResult.skipped_count - 5} more
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Errors */}
|
|
{syncResult.errors.length > 0 && (
|
|
<div className="p-2 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded text-sm">
|
|
<div className="text-red-700 dark:text-red-400 font-medium mb-1">Errors:</div>
|
|
<ul className="text-xs text-red-700/90 dark:text-red-300/80 space-y-0.5">
|
|
{syncResult.errors.map((err, i) => (
|
|
<li key={i}>{err}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
|
|
{showAmsSyncConfirm && (
|
|
<ConfirmModal
|
|
title={t('settings.amsSyncTitle')}
|
|
message={t('settings.amsSyncMessage')}
|
|
confirmText={t('settings.amsSyncButton')}
|
|
variant="warning"
|
|
isLoading={amsSyncMutation.isPending}
|
|
loadingText={t('settings.amsSyncing')}
|
|
onConfirm={() => amsSyncMutation.mutate()}
|
|
onCancel={() => setShowAmsSyncConfirm(false)}
|
|
/>
|
|
)}
|
|
|
|
{showSpoolmanAmsSyncConfirm && (
|
|
<ConfirmModal
|
|
title={t('settings.spoolmanAmsSyncTitle')}
|
|
message={t('settings.spoolmanAmsSyncMessage')}
|
|
confirmText={t('settings.spoolmanAmsSyncButton')}
|
|
variant="warning"
|
|
isLoading={spoolmanAmsSyncMutation.isPending}
|
|
loadingText={t('settings.spoolmanAmsSyncing')}
|
|
onConfirm={() => spoolmanAmsSyncMutation.mutate()}
|
|
onCancel={() => setShowSpoolmanAmsSyncConfirm(false)}
|
|
/>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|