Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/dashboard/[brand]/production/fleet-sync-actions.ts
Original file line number Diff line number Diff line change
@@ -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<ActionResult<FleetSyncResult>> {
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 }
}
}
15 changes: 11 additions & 4 deletions app/dashboard/[brand]/production/machines/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -44,10 +45,16 @@ export default async function MachineManagementPage({
intervalSeconds={env.NEXT_PUBLIC_PRODUCTION_REFRESH_SECONDS}
enabled={env.NEXT_PUBLIC_PRODUCTION_REFRESH_SECONDS !== undefined}
/>
<ProductionPageHeader
title="Machine Management"
description="Live status and print progress for every printer."
/>
{/* 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. */}
<div className="flex flex-wrap items-start justify-between gap-4">
<ProductionPageHeader
title="Machine Management"
description="Live status and print progress for every printer."
/>
<FleetSyncButton brand={brand} />
</div>
{error ? (
<p role="alert" className="bg-danger-subtle text-danger rounded-md px-3 py-2 text-sm">
{error}
Expand Down
90 changes: 90 additions & 0 deletions components/production/fleet-sync-button.tsx
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 26 in components/production/fleet-sync-button.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Optiminastic_Tensor&issues=AaAjHuIHh8df51YGM69y&open=AaAjHuIHh8df51YGM69y&pullRequest=12
const router = useRouter()
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<string | null>(null)

async function sync(): Promise<void> {
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 (
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="secondary"
size="sm"
disabled={pending}
onClick={() => void sync()}
>
<RefreshCw className={pending ? 'size-3.5 animate-spin' : 'size-3.5'} aria-hidden />
{pending ? 'Syncing…' : 'Sync from BambuBuddy'}
</Button>
{error ? (
<p role="alert" className="text-danger text-xs">
{error}
</p>
) : null}
{result ? (
<p role="status" className="text-muted-foreground text-xs">

Check warning on line 84 in components/production/fleet-sync-button.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=Optiminastic_Tensor&issues=AaAjHuIHh8df51YGM69z&open=AaAjHuIHh8df51YGM69z&pullRequest=12
{result}
</p>
) : null}
</div>
)
}
15 changes: 15 additions & 0 deletions lib/validators/machine-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,18 @@ export type FleetMachineLive = z.infer<typeof FleetMachineLiveSchema>
export type FleetMachineLiveAms = z.infer<typeof LiveAmsSchema>
export type FleetMachineLiveNozzle = z.infer<typeof LiveNozzleSchema>
export type FleetMachineLiveTemperature = z.infer<typeof LiveTemperatureSchema>

/**
* 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<typeof FleetSyncResultSchema>
15 changes: 15 additions & 0 deletions services/machine-fleet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logger'
import {
type FleetSyncResult,
FleetSyncResultSchema,
type FleetMachine,
type FleetMachineLive,
FleetMachineLiveSchema,
Expand Down Expand Up @@ -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<FleetSyncResult> {
return call('/machine-fleet/sync', { method: 'POST', headers: jsonHeaders(token) }, data =>
FleetSyncResultSchema.parse(data),
)
}
Loading