diff --git a/app/dashboard/[brand]/production/fleet-sync-actions.ts b/app/dashboard/[brand]/production/fleet-sync-actions.ts new file mode 100644 index 0000000..3949c74 --- /dev/null +++ b/app/dashboard/[brand]/production/fleet-sync-actions.ts @@ -0,0 +1,37 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +import { resolveBackendToken } from '@/lib/backend-token' +import { type FleetSyncResult } from '@/lib/validators/machine-fleet' +import { + MachineFleetServiceError, + syncFleetMachines as syncFleetMachinesCall, +} from '@/services/machine-fleet.service' + +import type { ActionResult } from './actions' + +/** + * Reconciles Machine Management with the printers BambuBuddy reports. + * + * This is the only thing that populates the fleet - nothing syncs on a timer or + * at startup - so on a fresh deployment the machines page stays empty until + * someone runs this. That is deliberate (a background poll against a printer + * host that may be asleep is worse), but it does mean the page needs a way to + * trigger it, which is what this backs. + */ +export async function syncFleetAction(brand: string): Promise> { + const { token, error } = await resolveBackendToken() + if (!token) return { ok: false, error } + try { + const result = await syncFleetMachinesCall(token) + revalidatePath(`/dashboard/${brand}/production/machines`) + return { ok: true, data: result } + } catch (err) { + const message = + err instanceof MachineFleetServiceError + ? err.message + : 'Could not reach BambuBuddy to sync the fleet.' + return { ok: false, error: message } + } +} diff --git a/app/dashboard/[brand]/production/machines/page.tsx b/app/dashboard/[brand]/production/machines/page.tsx index 586bc9e..c72e69c 100644 --- a/app/dashboard/[brand]/production/machines/page.tsx +++ b/app/dashboard/[brand]/production/machines/page.tsx @@ -3,6 +3,7 @@ import type { JSX } from 'react' import { AutoRefresh } from '@/components/production/auto-refresh' import { FleetMachineTable } from '@/components/production/fleet-machine-table' +import { FleetSyncButton } from '@/components/production/fleet-sync-button' import { ProductionPageHeader } from '@/components/production/production-page-header' import { requirePermission } from '@/lib/authz' import { resolveBackendToken } from '@/lib/backend-token' @@ -44,10 +45,16 @@ export default async function MachineManagementPage({ intervalSeconds={env.NEXT_PUBLIC_PRODUCTION_REFRESH_SECONDS} enabled={env.NEXT_PUBLIC_PRODUCTION_REFRESH_SECONDS !== undefined} /> - + {/* The sync sits beside the header, matching the Orders page. Without it + the fleet can only be populated by calling the API directly, which is + why a fresh deployment showed an empty table with no way forward. */} +
+ + +
{error ? (

{error} diff --git a/components/production/fleet-sync-button.tsx b/components/production/fleet-sync-button.tsx new file mode 100644 index 0000000..211978a --- /dev/null +++ b/components/production/fleet-sync-button.tsx @@ -0,0 +1,90 @@ +'use client' + +import { RefreshCw } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useState, type JSX } from 'react' + +import { syncFleetAction } from '@/app/dashboard/[brand]/production/fleet-sync-actions' +import { Button } from '@/components/ui/button' + +interface FleetSyncButtonProps { + brand: string +} + +/** + * Pulls the printer fleet from BambuBuddy on demand. + * + * This is the only thing that populates Machine Management - nothing syncs on a + * timer or at startup - so a fresh deployment shows an empty table until + * someone presses this. Deliberate: polling a printer host that may be asleep, + * on a laptop, over a VPN, is worse than an explicit action. But it does mean + * the page has to offer the action, or the fleet is unreachable from the + * browser entirely. + * + * Mirrors OrdersSyncButton, which makes the same trade for Shopify orders. + */ +export function FleetSyncButton({ brand }: FleetSyncButtonProps): JSX.Element { + const router = useRouter() + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + + async function sync(): Promise { + setPending(true) + setError(null) + setResult(null) + + const res = await syncFleetAction(brand) + setPending(false) + + if (!res.ok) { + setError(res.error ?? 'Could not reach BambuBuddy to sync the fleet.') + return + } + + const data = res.data + const synced = data?.synced ?? 0 + const removed = data?.removed ?? 0 + + // Zero printers is reported plainly rather than as a success. BambuBuddy + // answered but has no printers registered, which looks identical to a + // broken connection on a table that was already empty. + if (synced === 0 && removed === 0) { + setError('BambuBuddy answered, but reported no printers.') + return + } + + const names = data?.names?.length ? `: ${data.names.join(', ')}` : '' + setResult( + removed > 0 + ? `${synced} printer(s) synced${names}, ${removed} removed.` + : `${synced} printer(s) synced${names}.`, + ) + router.refresh() + } + + return ( +

+ + {error ? ( +

+ {error} +

+ ) : null} + {result ? ( +

+ {result} +

+ ) : null} +
+ ) +} diff --git a/lib/validators/machine-fleet.ts b/lib/validators/machine-fleet.ts index 2eafb66..5114d66 100644 --- a/lib/validators/machine-fleet.ts +++ b/lib/validators/machine-fleet.ts @@ -106,3 +106,18 @@ export type FleetMachineLive = z.infer export type FleetMachineLiveAms = z.infer export type FleetMachineLiveNozzle = z.infer export type FleetMachineLiveTemperature = z.infer + +/** + * What reconciling the fleet against BambuBuddy changed. + * + * `synced` counts printers created or refreshed, `removed` counts fleet entries + * BambuBuddy no longer reports. `names` is what the operator actually wants to + * see - "which printers do I now have" is a more useful answer than a count. + */ +export const FleetSyncResultSchema = z.object({ + synced: z.number(), + removed: z.number(), + names: z.array(z.string()).nullish(), +}) + +export type FleetSyncResult = z.infer diff --git a/services/machine-fleet.service.ts b/services/machine-fleet.service.ts index cd58df7..c8f51f8 100644 --- a/services/machine-fleet.service.ts +++ b/services/machine-fleet.service.ts @@ -2,6 +2,8 @@ import { env } from '@/lib/env' import { createLogger } from '@/lib/logger' import { + type FleetSyncResult, + FleetSyncResultSchema, type FleetMachine, type FleetMachineLive, FleetMachineLiveSchema, @@ -117,3 +119,16 @@ export async function uploadToPrinter( data => data as PrinterUploadResult, ) } + +/** + * Reconciles the fleet with BambuBuddy: every printer it reports is created or + * refreshed, anything it no longer reports is removed. + * + * Nothing does this on a schedule, so a fresh deployment's Machine Management + * page is empty until this runs at least once. + */ +export async function syncFleetMachines(token: string): Promise { + return call('/machine-fleet/sync', { method: 'POST', headers: jsonHeaders(token) }, data => + FleetSyncResultSchema.parse(data), + ) +}