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&apos;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 };