channel-sync-health-panel.tsx8.3 KBView on GitHub 'use client';
import { AlertTriangle, RefreshCw, Unplug } from 'lucide-react';
import type { inferRouterOutputs } from '@trpc/server';
import { useTRPC } from '@/providers/query-provider';
import { Skeleton } from '@/components/ui/skeleton';
import type { AppRouter } from '@zero/server/trpc';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
/**
* Channel sync health, translated into something a rep can act on.
* (design: apps/server/docs/channel-sync-architecture.md, Phase 13)
*
* This used to be ONE panel above the connection grid, reporting on all three channels at once.
* That was wrong twice over. A banner about Slack sat above the Email, CRM and Drive cards it has
* nothing to do with; and when every channel was healthy but the report was not `ok`, the card
* rendered its "Message sync" heading over an empty body — a container with nothing in it.
*
* Health now lives WITH its channel: `ChannelSyncBadge` on the channel's own card (so a dead
* token cannot look green from the grid) and `ChannelSyncStatus` inside that channel's dialog,
* where the reconnect button is. Both render nothing when there is nothing to say, which is the
* state almost all of the time.
*
* The design's whole complaint is that sync defects were invisible: the envelope was 129-diverged
* for an unknown length of time and 1,023 Slack messages sat quarantined behind a dead connection
* while the screen cheerfully showed a green "Connected" dot. This panel exists so that failure
* reads as "Slack sync paused — reconnect" instead of messages that quietly never arrive.
*
* It deliberately does NOT dump `channels.health`'s metrics. Almost all of that report is either
* a resting state or a queue that drains on its own, and a panel that renders those as problems
* is a panel people learn to ignore — see `isPaused` for the one number allowed to raise a flag.
*/
type ChannelHealthReport = inferRouterOutputs<AppRouter>['channels']['health'];
type ChannelHealth = ChannelHealthReport['slack'];
const CHANNELS = [
{ key=[redacted], label: 'Slack' },
{ key=[redacted], label: 'LinkedIn' },
{ key=[redacted], label: 'WhatsApp' },
] as const;
type ChannelKey=[redacted] CHANNELS)[number]['key'];
const formatCount = (n: number) => n.toLocaleString();
/**
* The ONE condition on this screen that a person has to act on.
*
* `quarantined` is buffered work parked on a terminal/auth error. Those messages exist, they are
* addressed to this org, and no amount of waiting delivers them — the connection has to be
* re-established by a person.
*
* EVERYTHING ELSE IN THE REPORT IS DELIBERATELY ABSENT, and the reason is the same for all of it:
* nothing on this screen is the thing that fixes it.
*
* - `envelopeDivergence`, `messagesWithoutContainer`, `duplicateMessages`, `unreadOverInbound` —
* mechanically repairable from data already stored, and `processChannelReconcile` ALREADY
* repairs them, hourly, per org, bounded at 200 per channel (aws/lib/stacks/app-stack.ts,
* `cronTasksOnTheHour`). The "Repair sync" button that used to sit here called
* `channels.reconcile` — the identical function, with the identical default limit of 200 — so
* it asked a rep to press a button to start work that was already going to happen within the
* hour, about a defect they did not cause and cannot evaluate. These numbers are operator
* telemetry: they belong in the cron's `channels.health` log and in
* `pnpm cedar-cli channels health|reconcile`, both of which still carry them.
* - `unlinked` / `unlinkedWithEvidence` — a channel conversation not attached to a deal is a
* RESTING STATE. Most chats are not deals and never will be. The same hourly cron re-asks the
* linking ladder over stored evidence (`sweepPendingLinks`), so the ones that CAN be linked get
* linked; the ones that genuinely need a person are the `ambiguous` verdicts, and those have
* their own surface in `ChannelLinkProposalsPanel` at the top of the page.
* - `containersWithoutBodies` — "a container we mirror but have no messages for; NOT a defect, a
* backlog for catch-up". It is 116 of 118 WhatsApp chats at rest and never reaches 0.
* - `buffered` / `oldestBufferAgeSec` — work that drains on its own. No button on this screen can
* move it, and the Phase 13 cron already notifies when a bucket outlives one drain interval.
*
* The test for putting anything back: is there something this rep, on this screen, can do that
* would not otherwise happen? For quarantine there is — reconnect. For the rest there is not.
*/
const isPaused = (h: ChannelHealth) => h.quarantined > 0;
/** One shared query for the badge and the dialog body — they always agree, and it fetches once. */
function useChannelHealth() {
const trpc = useTRPC();
return useQuery(trpc.channels.health.queryOptions());
}
/** Does this channel have anything worth saying? Drives whether either component renders. */
const hasSomethingToSay = (h: ChannelHealth) => h.containers > 0 && isPaused(h);
/**
* The chip on a channel's card in the grid.
*
* The design's original complaint was that a dead connection still showed a green tick. That is
* fixed here rather than in a banner: the tick and this chip sit on the same card, so the card
* cannot claim to be fine while its sync is stopped.
*/
export function ChannelSyncBadge({ channel }: { channel: ChannelKey }) {
const { data } = useChannelHealth();
const h = data?.[channel];
if (!h || !hasSomethingToSay(h)) return null;
return (
<span className="bg-destructive/10 text-destructive inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
<AlertTriangle className="size-3" />
Sync paused
</span>
);
}
interface ChannelSyncStatusProps {
channel: ChannelKey;
/** Reconnecting is the channel's own flow; this screen already owns it. */
onReconnect: (channel: ChannelKey) => void;
className?: string;
}
/**
* The detail, inside the channel's own dialog: what is stuck, and the button that unsticks it.
* Renders nothing when the channel is healthy — a healthy channel does not need a paragraph
* telling it so, and a screen full of green reassurance is a screen people stop reading.
*/
export function ChannelSyncStatus({ channel, onReconnect, className }: ChannelSyncStatusProps) {
const { data, isLoading, isError, refetch } = useChannelHealth();
if (isLoading) return <Skeleton className={cn('h-16 w-full rounded-lg', className)} />;
// A failed health check is not a sync failure, and claiming otherwise would send reps chasing a
// reconnect they do not need. It is also not worth a card of its own — one quiet line.
if (isError || !data) {
return (
<div className={cn('flex items-center justify-between gap-3', className)}>
<p className="text-muted-foreground text-xs">Couldn't check sync status.</p>
<Button variant="ghost" size="sm" className="cursor-pointer" onClick={() => void refetch()}>
<RefreshCw className="size-4" />
Retry
</Button>
</div>
);
}
const h = data[channel];
const label = CHANNELS.find((c) => c.key === channel)?.label ?? channel;
if (!hasSomethingToSay(h)) return null;
return (
<div
className={cn(
'border-destructive/40 bg-destructive/5 flex flex-col gap-3 rounded-lg border p-3',
'sm:flex-row sm:items-center sm:justify-between',
className,
)}
>
<div className="flex min-w-0 items-start gap-2">
<AlertTriangle className="text-destructive mt-0.5 size-4 shrink-0" />
<div className="min-w-0 space-y-0.5">
<p className="text-destructive text-sm font-medium">Sync paused — reconnect</p>
<p className="text-muted-foreground text-xs">
{formatCount(h.quarantinedMessages)}{' '}
{h.quarantinedMessages === 1 ? 'message is' : 'messages are'} held back because {label}{' '}
stopped accepting the connection. Reconnecting releases them.
</p>
</div>
</div>
<Button
size="sm"
variant="secondary"
className="shrink-0 cursor-pointer"
onClick={() => onReconnect(channel)}
>
<Unplug className="size-4" />
Reconnect
</Button>
</div>
);
}
export type { ChannelKey };