moadim 3.4.0

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useAllRuns, useMaxConcurrentRuns, type FleetRunSummary } from "../../api/hooks";
import { abstime, reltime } from "../../lib/cronUtils";
import { fmtRunDuration, runStatusClass, runStatusLabel } from "../../lib/runDisplay";
import { useNow } from "../../lib/useNow";
import { RefreshFreshness, refreshMs, useRefreshToken } from "../../components/RefreshControl";
import {
  DEFAULT_RUNS_FILTER,
  TIME_FACET_SECS,
  filterRuns,
  statusCounts,
  type RunStatusFacet,
  type RunTimeFacet,
} from "./runsFilter";
import { RunsHistogram } from "./RunsHistogram";
import { buildHistogram, runsInRange } from "./histogramMath";
import { RunsGantt } from "./RunsGantt";
import { SortableTh } from "../../components/SortableTh";
import { sortRows, usePersistedSort, type SortAccessors } from "../../lib/tableSort";
import { buildGantt } from "./ganttMath";

const INITIAL_LIMIT = 100;
const LOAD_MORE_STEP = 100;
const MAX_LIMIT = 1_000;

const RUN_SORT_KEYS = ["routine", "started", "duration", "status", "exit"] as const;
type RunSortKey = (typeof RUN_SORT_KEYS)[number];

const RUN_SORT: SortAccessors<FleetRunSummary, RunSortKey> = {
  routine: (r) => r.routine_title,
  started: (r) => r.started_at,
  duration: (r) => (r.finished_at == null ? null : r.finished_at - r.started_at),
  status: (r) => r.status,
  exit: (r) => r.exit_code ?? null,
};

const STATUS_OPTIONS: [RunStatusFacet, string][] = [
  ["all", "All"],
  ["success", "Success"],
  ["failed", "Failed"],
  ["running", "Running"],
  ["unknown", "Unknown"],
];

const TIME_OPTIONS: [RunTimeFacet, string][] = [
  ["all", "All time"],
  ["1h", "Last hour"],
  ["24h", "Last 24h"],
  ["7d", "Last 7 days"],
];

/**
 * The RUNS page: a fleet-wide, filterable activity feed of every past run
 * across every routine — the build-history list CI dashboards (GitHub
 * Actions, CircleCI) and cron managers ship, so an operator can answer
 * "what ran, and how did it go" without opening each routine's own history
 * or scrolling Overview's fixed 8-row recent-runs panel. Built entirely on
 * the existing `GET /routines/runs` fleet endpoint.
 */
export function RunsPage() {
  const refreshToken = useRefreshToken();
  const ms = refreshMs(refreshToken);
  const nowSecs = Math.floor(useNow(30_000) / 1000);

  const [limit, setLimit] = useState(INITIAL_LIMIT);
  const [filter, setFilter] = useState(DEFAULT_RUNS_FILTER);

  const runsQuery = useAllRuns(limit, { refetchInterval: ms });
  const runs = useMemo(() => runsQuery.data ?? [], [runsQuery.data]);
  const [range, setRange] = useState<{ from: number; to: number } | null>(null);
  const filtered = useMemo(() => filterRuns(runs, filter, nowSecs), [runs, filter, nowSecs]);
  const windowFrom = filter.time === "all" ? undefined : nowSecs - TIME_FACET_SECS[filter.time];
  const histogram = useMemo(() => buildHistogram(filtered, nowSecs, windowFrom), [filtered, nowSecs, windowFrom]);
  const shown = useMemo(() => (range === null ? filtered : runsInRange(filtered, range)), [filtered, range]);
  const gantt = useMemo(() => buildGantt(shown, nowSecs, range?.from ?? windowFrom), [shown, nowSecs, range, windowFrom]);
  const cap = useMaxConcurrentRuns().data?.value;
  const counts = useMemo(() => statusCounts(runs), [runs]);
  const { sort, toggle: toggleSort } = usePersistedSort("moadim.client.runs.sort", RUN_SORT_KEYS);
  const sorted = useMemo(() => sortRows(shown, sort, RUN_SORT), [shown, sort]);

  const canLoadMore = runs.length >= limit && limit < MAX_LIMIT;

  return (
    <div className="page">
      <div className="section-hd">
        <h1 className="page-title">Runs</h1>
        <div className="section-acts">
          <RefreshFreshness updatedAtMs={runsQuery.dataUpdatedAt} />
        </div>
      </div>

      <div className="stats">
        <div className="stat-card">
          <div className="stat-label">FETCHED</div>
          <div className="stat-val">{runs.length}</div>
        </div>
        <div className="stat-card">
          <div className="stat-label">RUNNING</div>
          <div className={counts.running > 0 ? "stat-val c-accent" : "stat-val"}>{counts.running}</div>
        </div>
        <div className="stat-card">
          <div className="stat-label">SUCCESS</div>
          <div className="stat-val">{counts.success}</div>
        </div>
        <div className="stat-card">
          <div className="stat-label">FAILED</div>
          <div className={counts.failed > 0 ? "stat-val c-red" : "stat-val"}>{counts.failed}</div>
        </div>
      </div>

      <div className="filter-bar">
        <div className="filter-field">
          <input
            type="text"
            className="filter-input"
            placeholder="Search runs by routine…"
            aria-label="Search runs"
            value={filter.query}
            onChange={(e) => setFilter({ ...filter, query: e.target.value })}
          />

          <span className="filter-label">STATUS</span>
          <select
            className="filter-select"
            aria-label="Status filter"
            value={filter.status}
            onChange={(e) => setFilter({ ...filter, status: e.target.value as RunStatusFacet })}
          >
            {STATUS_OPTIONS.map(([value, label]) => (
              <option key={value} value={value}>
                {label}
              </option>
            ))}
          </select>

          <span className="filter-label">WHEN</span>
          <select
            className="filter-select"
            aria-label="Time range filter"
            value={filter.time}
            onChange={(e) => {
              setFilter({ ...filter, time: e.target.value as RunTimeFacet });
              setRange(null);
            }}
          >
            {TIME_OPTIONS.map(([value, label]) => (
              <option key={value} value={value}>
                {label}
              </option>
            ))}
          </select>
        </div>
        <div className="filter-field">
          <span className="filter-count">
            Showing {shown.length} of {runs.length} fetched
          </span>
          {range !== null && (
            <button type="button" className="btn btn-ghost" onClick={() => setRange(null)}>
              Clear time selection ({abstime(range.from)} – {abstime(range.to)}) ✕
            </button>
          )}
        </div>
      </div>

      {runsQuery.data !== undefined && (
        <RunsHistogram
          histogram={histogram}
          selected={range?.from ?? null}
          onSelect={(b) => setRange(b === null ? null : { from: b.from, to: b.to })}
        />
      )}

      <RunsGantt gantt={gantt} cap={cap} />

      {runsQuery.error ? (
        <div className="table-wrap">
          <div className="empty">
            <div className="empty-icon">⚠</div>
            <div className="empty-msg">FAILED TO LOAD</div>
            <div className="empty-sub">{runsQuery.error.message}</div>
          </div>
        </div>
      ) : runsQuery.isLoading ? (
        <div className="table-wrap">
          <div className="empty">
            <div className="spinner" />
          </div>
        </div>
      ) : shown.length === 0 ? (
        <div className="table-wrap">
          <div className="empty">
            <div className="empty-icon">⧗</div>
            <div className="empty-msg">NO RUNS</div>
            <div className="empty-sub">
              {runs.length === 0 ? "no runs yet" : "no runs match the current filters"}
            </div>
          </div>
        </div>
      ) : (
        <div className="table-wrap">
          <table>
            <thead>
              <tr>
                <SortableTh label="ROUTINE" col="routine" sort={sort} defaultDir="asc" onSort={toggleSort} />
                <SortableTh label="STARTED" col="started" sort={sort} defaultDir="desc" onSort={toggleSort} />
                <SortableTh label="DURATION" col="duration" sort={sort} defaultDir="desc" onSort={toggleSort} />
                <SortableTh label="STATUS" col="status" sort={sort} defaultDir="asc" onSort={toggleSort} />
                <SortableTh label="EXIT CODE" col="exit" sort={sort} defaultDir="desc" onSort={toggleSort} />
                <th />
              </tr>
            </thead>
            <tbody>
              {sorted.map((run) => (
                <tr key={run.workbench}>
                  <td>
                    <Link to={`/routines?history=${encodeURIComponent(run.routine_id)}`}>{run.routine_title}</Link>
                  </td>
                  <td>
                    <div className="cell-time" title={abstime(run.started_at)}>
                      {reltime(run.started_at)}
                    </div>
                  </td>
                  <td>
                    <span className="cell-meta">
                      {run.finished_at == null ? "—" : fmtRunDuration(run.started_at, run.finished_at)}
                    </span>
                  </td>
                  <td>
                    <span className={runStatusClass(run.status)}>{runStatusLabel(run.status)}</span>
                  </td>
                  <td>{run.exit_code == null ? "—" : run.exit_code}</td>
                  <td>
                    <Link
                      to={`/runs/${encodeURIComponent(run.routine_id)}/${encodeURIComponent(run.workbench)}`}
                      className="act-btn logs"
                    >
                      VIEW RUN
                    </Link>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {canLoadMore && (
        <div className="section-acts">
          <button
            type="button"
            className="btn btn-ghost"
            onClick={() => setLimit((n) => Math.min(MAX_LIMIT, n + LOAD_MORE_STEP))}
          >
            Load more
          </button>
        </div>
      )}
    </div>
  );
}