# Monitor card

A rule that watches something and says when it needs a person: what it watches, when it runs, the condition it alerts on, what it last found, its state in a word beside an icon ("Watching", "Needs attention", "Paused", "Not known"), and an on/off switch showing what the service reports.

The switch never claims a change that hasn't happened. While `onToggle`'s promise runs it stays where it was, the card says "Turning on" or "Turning off", and the switch is `aria-disabled` with those words as its reason; when the promise settles the switch shows the `on` the app passes back. A rejection says why, once, and leaves the switch as reported. Focus stays on the switch throughout. With `on` null the service didn't say, and the card says so instead of drawing a switch. Without `onToggle` there is nothing to turn it with, so `on` is said in words ("On", "Off").

A monitor that can't run stays on the page and says why (`unavailable`): its switch is locked with the reason, and the reason is printed on the card beside a lock. `layout="row"` draws the same fields as a compact row for a list, beside `ScheduleList`.

- Registry name: `monitor-card` (`@candor/monitor-card`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/monitor-card

## Install

```bash
npx candor-ui add monitor-card
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/monitor-card`.
It brings in: bidi, locale, locked, relative-time, switch, tokens, use-announcer, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { Activity, BadgeCheck, Database, KeyRound, ShieldAlert, UserMinus } from "lucide-react"
import { MonitorCard, type MonitorFinding, type MonitorState } from "@/components/candor/monitor-card"

type Monitor = {
  id: string
  name: string
  category: string
  icon: typeof Activity
  watches: string
  runs: string
  alertWhen: string
  state: MonitorState
  on: boolean
  finding?: MonitorFinding
  lastRunAt?: string | null
  unavailable?: string
}

const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString()

const start: Monitor[] = [
  { id: "certs", name: "Certificate expiry", category: "Security", icon: BadgeCheck, watches: "The signing certificates every connection uses.", runs: "Daily", alertWhen: "30 days before one expires", state: "attention", on: true, finding: { text: "Payments signing certificate expires in 21 days", at: hoursAgo(5) }, lastRunAt: hoursAgo(5) },
  { id: "keys", name: "Key rotation", category: "Security", icon: KeyRound, watches: "How old each API key is.", runs: "Weekly", alertWhen: "A key is older than 90 days", state: "watching", on: true, lastRunAt: hoursAgo(30) },
  { id: "leavers", name: "Leavers with access", category: "People", icon: UserMinus, watches: "Accounts of people who have left and can still sign in.", runs: "On every directory sync", alertWhen: "A leaver can still sign in", state: "paused", on: false, lastRunAt: hoursAgo(200) },
  { id: "storage", name: "Storage growth", category: "Usage", icon: Database, watches: "How fast stored data grows.", runs: "Daily", alertWhen: "Growth passes 5% in a week", state: "watching", on: true, lastRunAt: null },
  { id: "events", name: "Unusual sign-ins", category: "Security", icon: ShieldAlert, watches: "Sign-ins from new places and devices.", runs: "Hourly", alertWhen: "Three new places in an hour", state: "unknown", on: false, unavailable: "Needs the event log add-on. This workspace isn’t licensed for it, so the monitor can’t run." },
  { id: "api", name: "API limits", category: "Usage", icon: Activity, watches: "How close each integration is to its daily API limit.", runs: "Hourly", alertWhen: "An integration passes 80% of its limit", state: "unknown", on: false, unavailable: "Needs read access to usage, which the connection doesn’t have." },
]

export default function Example() {
  const [monitors, setMonitors] = React.useState(start)
  // Stands in for your service: it answers after a moment, and the card shows what it reports.
  const toggle = (id: string) => async (on: boolean) => {
    await new Promise((resolve) => setTimeout(resolve, 900))
    if (id === "leavers" && on) throw new Error("the directory connection has expired")
    setMonitors((all) => all.map((m) => (m.id === id ? { ...m, on, state: on ? "watching" : "paused" } : m)))
  }
  return (
    <div className="flex flex-col gap-6">
      <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
        {monitors.map((m) => (
          <MonitorCard key={m.id} {...m} onToggle={toggle(m.id)} />
        ))}
      </div>
      <div className="flex flex-col gap-2">
        {monitors.slice(0, 3).map((m) => (
          <MonitorCard key={m.id} layout="row" {...m} onToggle={toggle(m.id)} />
        ))}
      </div>
    </div>
  )
}
```

## <MonitorCard>

Props:

- `name: string`: The monitor's name. It names the switch.
- `category?: string | undefined`: The kind of monitor, shown under its name: "Security", "Usage".
- `icon?: LucideIcon | undefined`: An icon for the monitor, drawn in a tile beside its name.
- `watches: string`: What it watches, in a sentence.
- `runs: string`: When it runs, in words: "Daily", "On every sync".
- `alertWhen: string`: The condition it alerts on, in words: "30 days before a certificate expires".
- `state: MonitorState`: Its state as the service reports it.
- `stateLabel?: string | undefined`: The app's word for the state, in place of Candor's.
- `finding?: MonitorFinding | undefined`: What it last found: a tool's text, drawn as plain text with a warning icon.
- `lastRunAt?: string | null | undefined`: When it last ran (ISO date); null when it hasn't. Left out, nothing is said.
- `on?: boolean | null | undefined`: Whether it is on, as the service reports it; null when the service didn't say. Left out, there is no switch. Without `onToggle` (or `unavailable`), it is said in words ("On", "Off") rather than drawn as a switch.
- `onToggle?: ((on: boolean) => Promise<void> | void) | undefined`: Turns it on or off. The switch stays as reported until the promise settles and the app passes the new `on`.
- `unavailable?: string | undefined`: Why it can't run (a licence, a missing permission). The switch stays, locked with this reason, which is also printed on the card.
- `layout?: "card" | "row" | undefined` (default `"card"`): `"card"` (the default) or `"row"`, a compact row for a list.
- `headingLevel?: 2 | 3 | 4 | 5 | 6 | undefined` (default `3`): The level of the name's heading, so the card fits under the page's own headings. Defaults to 3 (`<h3>`).
- `className?: string | undefined`

## Strings (`monitorCard`)

The fixed words, English by default. Override any of them under `monitorCard` in `<LocaleProvider strings={{ monitorCard: { … } }}>`; the table is exported as `monitorCardStrings`.

- `watching`: Watching
- `attention`: Needs attention
- `paused`: Paused
- `unknown`: Not known
- `runs`: Runs
- `alertWhen`: Alerts when
- `lastRan`: Last ran {when}
- `neverRan`: Hasn’t run yet
- `foundAt`: Found {when}
- `turningOn`: Turning on
- `turningOff`: Turning off
- `onUnknown`: Not known whether it’s on
- `failedOn`: Couldn’t turn it on: {reason}
- `failedOff`: Couldn’t turn it off: {reason}
- `noReason`: the service didn’t say why
- `isOn`: On
- `isOff`: Off

## Types

### MonitorState

A monitor's state as the service reports it.

```ts
"watching" | "attention" | "paused" | "unknown"
```

### MonitorFinding

What a monitor last found: a line of a tool's text, and when.

```ts
{
  /** What it found, as text (a tool's words): "Signing certificate expires in 21 days". */
  text: string;
  /** When it was found (ISO date); null when that isn't recorded. */
  at?: string | null | undefined;
}
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
