zebflow 0.11.10

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { cx, useEffect, useState } from "zeb/react";
import { requestJson } from "@/components/lib/http";
import Button from "@/components/ui/button";
import CommitDialog from "@/components/ui/commit-dialog";
import ConfirmDialog from "@/components/ui/confirm-dialog";
import Field from "@/components/ui/field";
import Input from "@/components/ui/input";
import TraceCaptureFields from "@/components/ui/trace-capture-fields";
import { traceCaptureFormConfig, traceCaptureFormValues } from "@/components/lib/trace-capture";
import { settingsStatusToneClass } from "@/pages/project-studio/settings/components/settings-lib";
import InvocationStatsGrid from "@/pages/project-studio/settings/components/invocation-stats-grid";
import InvocationLogTable from "@/pages/project-studio/settings/components/invocation-log-table";

/**
 * Project defaults for invocation log capture and retention.
 *
 * Owns the stats and every way they change — loading, saving retention,
 * clearing one pipeline or all of them. The grid and the table below it only
 * display what this panel holds.
 */
export default function LoggingPanel({ api, invocationsApi, initialConfig }) {
  const [maxInv, setMaxInv] = useState(String(initialConfig?.max_invocations ?? 20));
  const [captureValues, setCaptureValues] = useState(traceCaptureFormValues(initialConfig?.trace_capture));
  const [statusMsg, setStatusMsg] = useState("Ready.");
  const [statusTone, setStatusTone] = useState("info");
  const [saving, setSaving] = useState(false);
  const [commitOpen, setCommitOpen] = useState(false);
  const [pendingData, setPendingData] = useState(null);
  const [stats, setStats] = useState(null);
  const [loadingStats, setLoadingStats] = useState(false);
  const [clearTarget, setClearTarget] = useState(null);
  const [clearing, setClearing] = useState(false);

  async function loadStats() {
    if (!invocationsApi) return;
    setLoadingStats(true);
    try {
      const resp = await requestJson(invocationsApi);
      setStats(resp?.stats ?? null);
    } catch (err) {
      setStatusMsg(`Failed to load logs: ${err?.message || String(err)}`);
      setStatusTone("error");
    } finally {
      setLoadingStats(false);
    }
  }

  useEffect(() => {
    loadStats();
  }, [invocationsApi]);

  function handleSubmit(e) {
    e.preventDefault();
    try {
      setPendingData({ max_invocations: parseInt(maxInv, 10) || 20, trace_capture: traceCaptureFormConfig(captureValues) });
      setCommitOpen(true);
    } catch (err) {
      setStatusMsg(err?.message || String(err));
      setStatusTone("error");
    }
  }

  async function handleCommit(commitMessage) {
    setCommitOpen(false);
    setSaving(true);
    setStatusMsg("Saving...");
    setStatusTone("info");
    try {
      const resp = await requestJson(api, {
        method: "PUT",
        body: JSON.stringify({ commit_message: commitMessage, data: pendingData }),
      });
      if (resp?.committed) {
        setStatusMsg("Saved & committed.");
        setStatusTone("ok");
      } else if (resp?.git_error) {
        setStatusMsg(`Saved (git: ${resp.git_error})`);
        setStatusTone("info");
      } else {
        setStatusMsg("Saved.");
        setStatusTone("ok");
      }
    } catch (err) {
      setStatusMsg(`Failed: ${err?.message || String(err)}`);
      setStatusTone("error");
    } finally {
      setSaving(false);
      setPendingData(null);
    }
  }

  async function handleClearLogs() {
    if (!invocationsApi || !clearTarget) return;
    setClearing(true);
    setStatusMsg("Clearing invocation logs...");
    setStatusTone("info");
    try {
      const url = clearTarget.file_rel_path
        ? `${invocationsApi}?pipeline=${encodeURIComponent(clearTarget.file_rel_path)}`
        : invocationsApi;
      const resp = await requestJson(url, { method: "DELETE" });
      setStatusMsg(`Cleared ${resp?.deleted ?? 0} invocation log row(s).`);
      setStatusTone("ok");
      await loadStats();
    } catch (err) {
      setStatusMsg(`Clear failed: ${err?.message || String(err)}`);
      setStatusTone("error");
    } finally {
      setClearing(false);
      setClearTarget(null);
    }
  }

  const pipelines = Array.isArray(stats?.pipelines) ? stats.pipelines : [];

  return (
    <article className="border border-border rounded-lg bg-card p-[0.85rem] mb-[0.9rem]">
      <CommitDialog
        open={commitOpen}
        section="logging"
        defaultMessage="settings(logging): update capture and retention config"
        onConfirm={handleCommit}
        onCancel={() => { setCommitOpen(false); setPendingData(null); }}
      />
      <ConfirmDialog
        open={!!clearTarget}
        title={clearTarget?.file_rel_path ? "Clear Pipeline Invocation Logs" : "Clear All Invocation Logs"}
        message={
          clearTarget?.file_rel_path
            ? `Delete invocation history for ${clearTarget.file_rel_path}? This cannot be undone.`
            : "Delete all invocation history for this project? This cannot be undone."
        }
        confirmLabel={clearing ? "Clearing..." : "Clear Logs"}
        cancelLabel="Cancel"
        variant="destructive"
        onConfirm={handleClearLogs}
        onClose={() => !clearing && setClearTarget(null)}
      />
      <header className="flex items-start justify-between gap-3 mb-[0.65rem]">
        <div>
          <h3 className="project-card-title">Pipeline Logging</h3>
          <p className="project-card-copy">Default data capture, retention, storage, and cleanup for project invocation logs.</p>
        </div>
        <span className="project-inline-chip">Logging</span>
      </header>
      <InvocationStatsGrid stats={stats} />

      <form className="grid grid-cols-2 gap-[0.65rem] mb-4" onSubmit={handleSubmit}>
        <Field label="Max Invocations Per Pipeline">
          <Input
            name="max_invocations"
            type="number"
            min="1"
            max="1000"
            value={maxInv}
            onInput={(e) => setMaxInv(e.currentTarget.value)}
          />
          <small className="pipeline-editor-field-help">
            How many invocation log entries to retain per pipeline. Oldest are dropped. Default: 20.
          </small>
        </Field>
        <TraceCaptureFields values={captureValues} onChange={setCaptureValues} scope="project" />
        <div className="col-span-full flex flex-wrap items-center gap-[0.7rem]">
          <Button
            type="submit"
            variant="primary"
            size="sm"
            disabled={saving}
            label={saving ? "Saving..." : "Save Logging Config"}
          />
          <Button
            type="button"
            variant="outline"
            size="sm"
            disabled={loadingStats}
            onClick={loadStats}
          >
            {loadingStats ? "Refreshing..." : "Refresh Stats"}
          </Button>
          <Button
            type="button"
            variant="destructive"
            size="sm"
            disabled={clearing || Number(stats?.count || 0) === 0}
            onClick={() => setClearTarget({ file_rel_path: null })}
          >
            Clear All Logs
          </Button>
          <span className={cx("text-[0.72rem]", settingsStatusToneClass(statusTone))}>{statusMsg}</span>
        </div>
      </form>

      <InvocationLogTable
        pipelines={pipelines}
        loadingStats={loadingStats}
        clearing={clearing}
        onClear={(filePath) => setClearTarget({ file_rel_path: filePath })}
      />
    </article>
  );
}