Files
legal-ai/web-ui/src/app/operations/page.tsx
Chaim 2f094b8d84
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
feat(operations): מסך "סוכנים פעילים" + ניהול ריצות (live-runs/log/cancel) (G12/X15, #119)
פאנל ב-/operations שמראה אילו סוכני Paperclip עובדים כעת (רצים+בתור), הפלט החי
שלהם, ושליטה מבוקרת: עצירת ריצה, איפוס session. סוגר את הנקודה-העיוורת שבה drain
מונע-סוכן (למשל ריקון תור הלכות ע"י ה-CEO heartbeat) עוקף את בקרת /operations
שמכירה רק שירותי pm2, והפלט הגולמי נגיש רק ב-Paperclip UI.

מקור-נתונים: Paperclip heartbeat-runs API (אומת חי):
  GET  /api/companies/{cid}/live-runs        — רצים+בתור (agentName/status/issue/outputSilence)
  GET  /api/heartbeat-runs/{id}/log          — NDJSON של פלט הסוכן
  GET  /api/heartbeat-runs/{id}/events        — timeline
  POST /api/heartbeat-runs/{id}/cancel        — עצירה מבוקרת (לא kill — מכבד watchdog+checkpoint)
  POST /api/agents/{id}/runtime-state/reset-session

ארכיטקטורה (G12/INV-PORT1): כל המגע החדש עם Paperclip דרך השער בלבד —
web/paperclip_client.py (shell) → re-export ב-web/agent_platform_port.py →
web/app.py צורך מהשער. leak_guard.py עובר (seam שלם). אסור kill ישיר על
process_pid (עוקף את השער).

Backend:
- paperclip_client: list_live_runs / get_run_log / get_run_events / cancel_run / reset_agent_session
- agent_platform_port: re-export pc_list_live_runs / pc_get_run_log / pc_get_run_events / pc_cancel_run / pc_reset_agent_session
- app.py: GET /api/operations/agents (אגרגציה CMP+CMPA, עמיד לכשל-חברה),
  GET .../runs/{id}/log, GET .../runs/{id}/events, POST .../runs/{id}/cancel,
  POST .../agents/{id}/reset-session

Frontend: פאנל "סוכנים פעילים" ב-/operations (polling 4s) + dialog ללוג חי
(פרסור NDJSON→טקסט קריא) + כפתורי עצור/אפס. הוספת hooks ל-operations.ts.

בטיחות: cancel על דריינר הלכות בטוח — חילוץ checkpointed per-chunk + resumable
+ self-heal לשורות processing.

Invariants: מקיים G12/INV-PORT1 (שער-הפלטפורמה). נוגע X6 (UI↔API).
api:types יורץ אחרי deploy (openapi.json חי).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:26:30 +00:00

682 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
useOperations,
useServiceAction,
useDrainToggle,
useAgentRuns,
useRunLog,
useCancelRun,
useResetAgentSession,
type OpsService,
type OperationsSnapshot,
type PipelineStats,
type AgentRun,
} from "@/lib/api/operations";
function mb(bytes: number): string {
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
}
function ago(ms: number): string {
if (!ms) return "—";
const secs = Math.floor((Date.now() - ms) / 1000);
if (secs < 60) return `לפני ${secs}ש׳`;
if (secs < 3600) return `לפני ${Math.floor(secs / 60)}ד׳`;
if (secs < 86400) return `לפני ${Math.floor(secs / 3600)}ש׳`;
return `לפני ${Math.floor(secs / 86400)}י׳`;
}
// Hebrew labels for the raw status strings the backend reports.
const STATUS_HE: Record<string, string> = {
online: "פעיל",
stopped: "עצור",
errored: "שגיאה",
launching: "עולה",
pending: "ממתין",
processing: "בעיבוד",
running: "רץ",
done: "הושלם",
completed: "הושלם",
failed: "נכשל",
manual: "ידני",
approved: "אושר",
pending_review: "ממתין לאישור",
rejected: "נדחה",
published: "פורסם",
deferred: "נדחה זמנית",
open: "פתוח",
closed: "סגור",
unknown: "לא ידוע",
};
function he(status: string): string {
return STATUS_HE[status] ?? status;
}
const STATUS_VARIANT: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
online: "default",
done: "default",
completed: "default",
approved: "default",
published: "default",
stopped: "secondary",
pending: "secondary",
pending_review: "secondary",
open: "secondary",
running: "outline",
processing: "outline",
launching: "outline",
failed: "destructive",
errored: "destructive",
manual: "destructive",
rejected: "destructive",
};
function StatusBadge({ value, count }: { value: string; count?: number }) {
return (
<Badge variant={STATUS_VARIANT[value] ?? "outline"} className="font-normal">
{he(value)}
{count !== undefined ? <span className="ms-1 font-semibold">{count}</span> : null}
</Badge>
);
}
const SERVICE_LABELS: Record<string, string> = {
"legal-court-fetch-service": "שירות אחזור פסיקה (דפדפן נט המשפט)",
"legal-court-fetch-xvfb": "צג וירטואלי (Xvfb) לדפדפן",
"legal-court-fetch-drain": "תזמון: ניקוז תור אחזור פסיקה (שעתי)",
"legal-metadata-drain": "תזמון: חילוץ מטא-דאטה (Gemini, ×15 דק׳)",
"legal-halacha-drain": "תזמון: חילוץ הלכות (Claude, ×שעתיים)",
"legal-digest-drain": "תזמון: העשרת יומונים (Sonnet, ×שעתיים)",
"legal-reaper": "מנקה תהליכים-יתומים (נגד דליפות זיכרון)",
"legal-chat-service": "שירות צ׳אט אימון (גשר ל-claude CLI)",
};
// ── Process-management panel (the "Windows services" view) ─────────────────
function ServiceControls({
s,
busy,
onAction,
onToggle,
}: {
s: OpsService;
busy: boolean;
onAction: (action: "restart" | "stop" | "start" | "run-now") => void;
onToggle: (disabled: boolean) => void;
}) {
const isCron = !!s.cron;
if (isCron) {
// Cron drain: "run now" (pm2 restart) + an enable/disable switch (DB flag).
return (
<div className="flex items-center gap-2 shrink-0">
<Button
size="xs"
variant="outline"
disabled={busy || s.disabled}
onClick={() => onAction("run-now")}
title={s.disabled ? "הפעל את התזמון כדי להריץ" : "הרץ את ה-drain פעם אחת מיד"}
>
הרץ עכשיו
</Button>
<label className="flex items-center gap-1.5 text-[0.7rem] text-ink-muted cursor-pointer">
<Switch
checked={!s.disabled}
disabled={busy}
onCheckedChange={(on) => {
if (on || confirm(`לכבות את התזמון "${SERVICE_LABELS[s.name] ?? s.name}"?`)) {
onToggle(!on);
}
}}
/>
{s.disabled ? "כבוי" : "פעיל"}
</label>
</div>
);
}
// Daemon: restart / stop / start.
const online = s.status === "online";
return (
<div className="flex items-center gap-1.5 shrink-0">
<Button size="xs" variant="outline" disabled={busy} onClick={() => onAction("restart")}>
הפעל מחדש
</Button>
{online ? (
<Button
size="xs"
variant="ghost"
disabled={busy}
className="text-destructive"
onClick={() => {
if (confirm(`לעצור את "${SERVICE_LABELS[s.name] ?? s.name}"?`)) onAction("stop");
}}
>
עצור
</Button>
) : (
<Button size="xs" variant="ghost" disabled={busy} onClick={() => onAction("start")}>
הפעל
</Button>
)}
</div>
);
}
function ServicesPanel({ data }: { data: OperationsSnapshot }) {
const action = useServiceAction();
const toggle = useDrainToggle();
const busy = action.isPending || toggle.isPending;
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-1">ניהול תהליכי-רקע (pm2)</h2>
<p className="text-ink-muted text-xs mb-4">
כמו &quot;שירותים&quot; ב-Windows. דמון = שירות רץ-תמיד (הפעל-מחדש/עצור/הפעל).
תזמון (cron) = רץ לפי לוח-זמנים (&quot;הרץ עכשיו&quot; להרצה מיידית, ומתג
הפעלה/כיבוי של התזמון).
</p>
{data.services_error ? (
<p className="text-sm text-destructive">{data.services_error}</p>
) : data.services.length === 0 ? (
<p className="text-sm text-ink-muted">אין שירותים.</p>
) : (
<div className="grid gap-2">
{data.services.map((s: OpsService) => {
const isCron = !!s.cron;
return (
<div
key={s.name}
className="flex items-center justify-between gap-3 rounded-md border border-rule-soft bg-rule-soft/30 px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{isCron ? (
<Badge
variant={s.disabled ? "destructive" : "default"}
className="font-normal"
>
{s.disabled ? "כבוי" : "פעיל (מתוזמן)"}
</Badge>
) : (
<StatusBadge value={s.status} />
)}
{s.cron ? (
<span className="text-[0.7rem] text-ink-muted font-mono" dir="ltr">
{s.cron}
</span>
) : null}
</div>
<div className="text-[0.8rem] text-navy truncate mt-0.5">
{SERVICE_LABELS[s.name] ?? s.name}
</div>
<div className="text-[0.66rem] text-ink-muted flex items-center gap-2 flex-wrap">
<span className="font-mono" dir="ltr">
{s.name}
</span>
<span>{mb(s.memory_bytes)}</span>
<span>{s.restarts}</span>
<span>{isCron ? `ריצה אחרונה ${ago(s.uptime_ms)}` : ago(s.uptime_ms)}</span>
</div>
</div>
<ServiceControls
s={s}
busy={busy}
onAction={(a) => action.mutate({ name: s.name, action: a })}
onToggle={(disabled) => toggle.mutate({ name: s.name, disabled })}
/>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
// ── Uniform queue stats ────────────────────────────────────────────────────
function StatTile({
label,
value,
tone,
title,
}: {
label: string;
value: number;
tone: "navy" | "muted" | "amber" | "green" | "red";
title?: string;
}) {
const toneClass = {
navy: "text-navy",
muted: "text-ink-muted",
amber: "text-gold-deep",
green: "text-emerald-600",
red: "text-destructive",
}[tone];
return (
<div
title={title}
className="flex flex-col items-center rounded-md border border-rule-soft px-2.5 py-1.5 min-w-[68px]"
>
<span className={`text-base font-semibold leading-none ${toneClass}`}>{value}</span>
<span className="text-[0.66rem] text-ink-muted mt-1 text-center">{label}</span>
</div>
);
}
function UniformStats({ p }: { p: PipelineStats }) {
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<StatTile
label="בתור"
value={p.queued}
tone="navy"
title="ממתינים שנדרשו במפורש לעיבוד — אלה שה-drain הבא יטפל בהם"
/>
<StatTile
label="ממתין (בקלוג)"
value={p.pending}
tone="muted"
title="כל הפריטים שטרם עובדו (ברירת-מחדל) — לאו דווקא בתור הפעיל"
/>
<StatTile label="בעיבוד" value={p.processing} tone="amber" />
<StatTile label="הושלם" value={p.done} tone="green" />
{p.failed > 0 ? <StatTile label="נכשל" value={p.failed} tone="red" /> : null}
</div>
{p.running_now.length > 0 ? (
<div className="text-[0.74rem] text-navy">
<span className="text-ink-muted">רץ עכשיו: </span>
{p.running_now.join(" · ")}
</div>
) : (
<div className="text-[0.72rem] text-ink-muted">אין פריט בעיבוד כרגע</div>
)}
</div>
);
}
function StatusRow({ by }: { by: Record<string, number> }) {
const entries = Object.entries(by).filter(([, n]) => n > 0);
if (entries.length === 0) return <span className="text-ink-muted text-sm">ריק</span>;
return (
<div className="flex flex-wrap gap-1.5">
{entries
.sort((a, b) => b[1] - a[1])
.map(([k, n]) => (
<StatusBadge key={k} value={k} count={n} />
))}
</div>
);
}
function PipelineCard({
title,
desc,
children,
}: {
title: string;
desc: string;
children: React.ReactNode;
}) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 space-y-2.5">
<div>
<h3 className="text-navy text-sm font-semibold mb-0.5">{title}</h3>
<p className="text-ink-muted text-[0.72rem]">{desc}</p>
</div>
{children}
</CardContent>
</Card>
);
}
// ── Live agents — who's working now + output + controls ────────────────────
// The platform's own liveness signal → a Hebrew label + tone.
const SILENCE_HE: Record<string, { label: string; tone: string }> = {
ok: { label: "פעיל", tone: "text-emerald-600" },
suspicion: { label: "שקט חשוד", tone: "text-gold-deep" },
critical: { label: "תקוע?", tone: "text-destructive" },
};
/** Best-effort: turn the captured NDJSON stream into readable lines (tail). */
function parseRunLog(content: string, maxLines = 400): string {
if (!content) return "";
const out: string[] = [];
for (const raw of content.split("\n")) {
const line = raw.trim();
if (!line) continue;
let chunk = line;
try {
const wrap = JSON.parse(line);
chunk = typeof wrap.chunk === "string" ? wrap.chunk : line;
} catch {
// not a wrapper line — keep raw
}
// The chunk is often a claude stream-json event; extract the human bits.
for (const part of chunk.split("\n")) {
const p = part.trim();
if (!p) continue;
try {
const ev = JSON.parse(p);
if (ev?.type === "assistant" && ev?.message?.content) {
const txt = (ev.message.content as Array<{ type: string; text?: string }>)
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("");
if (txt) out.push(txt);
} else if (ev?.type === "result" && typeof ev.result === "string") {
out.push(`${ev.result}`);
} else if (ev?.type === "system" && ev?.subtype) {
out.push(`· [${ev.subtype}]`);
} else {
out.push(p);
}
} catch {
out.push(p);
}
}
}
return out.slice(-maxLines).join("\n");
}
function RunLogDialog({ run, onClose }: { run: AgentRun | null; onClose: () => void }) {
const { data, isLoading, error } = useRunLog(run?.run_id ?? null);
const text = data ? parseRunLog(data.content) : "";
return (
<Dialog open={!!run} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>פלט הסוכן {run?.agent_name}</DialogTitle>
<DialogDescription>
{run?.company_label} · ריצה <span dir="ltr" className="font-mono">{run?.run_id?.slice(0, 8)}</span> · מתעדכן חי
</DialogDescription>
</DialogHeader>
{isLoading ? (
<Skeleton className="h-72 w-full" />
) : error ? (
<p className="text-sm text-destructive">שגיאה בטעינת הלוג: {String(error)}</p>
) : (
<ScrollArea className="h-[60vh] rounded-md border border-rule-soft bg-rule-soft/20 p-3">
<pre dir="ltr" className="text-[0.72rem] leading-relaxed whitespace-pre-wrap break-words text-navy text-start">
{text || "אין פלט עדיין."}
</pre>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
}
function LiveAgentsPanel() {
const { data, isLoading } = useAgentRuns();
const cancel = useCancelRun();
const reset = useResetAgentSession();
const [logRun, setLogRun] = useState<AgentRun | null>(null);
const busy = cancel.isPending || reset.isPending;
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
<h2 className="text-navy text-lg mb-0">סוכנים פעילים</h2>
{data ? (
<div className="flex items-center gap-2 text-[0.72rem]">
<Badge variant="default" className="font-normal">רצים {data.running}</Badge>
<Badge variant="secondary" className="font-normal">בתור {data.queued}</Badge>
</div>
) : null}
</div>
<p className="text-ink-muted text-xs mb-4">
מי מבין סוכני-הוועדה עובד כרגע ומה הפלט שלו כולל עבודה שלא קשורה לתיק (כמו
ריקון תור הלכות ע״י ה-CEO). עצירה היא מבוקרת דרך הפלטפורמה (לא kill).
</p>
{isLoading || !data ? (
<Skeleton className="h-24 w-full" />
) : data.runs.length === 0 ? (
<p className="text-sm text-ink-muted">אין סוכן פעיל כרגע.</p>
) : (
<div className="grid gap-2">
{data.errors.length > 0 ? (
<p className="text-[0.72rem] text-destructive">
לא ניתן לטעון חלק מהחברות: {data.errors.join(" · ")}
</p>
) : null}
{data.runs.map((r) => {
const sil = SILENCE_HE[r.silence_level];
const startMs = r.started_at ? Date.parse(r.started_at) : 0;
return (
<div
key={r.run_id}
className="flex items-center justify-between gap-3 rounded-md border border-rule-soft bg-rule-soft/30 px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant={r.status === "running" ? "default" : "secondary"}
className="font-normal"
>
{r.status === "running" ? "רץ" : "בתור"}
</Badge>
<span className="text-[0.85rem] text-navy font-semibold">{r.agent_name}</span>
{sil ? <span className={`text-[0.68rem] ${sil.tone}`}> {sil.label}</span> : null}
</div>
<div className="text-[0.66rem] text-ink-muted flex items-center gap-2 flex-wrap mt-0.5">
<span>{r.company_label}</span>
{r.status === "running" && startMs ? <span>החל {ago(startMs)}</span> : null}
{r.invocation_source ? (
<span dir="ltr" className="font-mono">{r.invocation_source}</span>
) : null}
{r.continuation_attempt > 0 ? <span>ניסיון #{r.continuation_attempt + 1}</span> : null}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button size="xs" variant="outline" onClick={() => setLogRun(r)}>
פלט
</Button>
<Button
size="xs"
variant="ghost"
className="text-destructive"
disabled={busy || r.status !== "running"}
onClick={() => {
if (confirm(`לעצור את הריצה של "${r.agent_name}"? (עצירה מבוקרת)`)) {
cancel.mutate(r.run_id);
}
}}
>
עצור
</Button>
<Button
size="xs"
variant="ghost"
disabled={busy}
title="איפוס session — ה-wakeup הבא יתחיל נקי"
onClick={() => {
if (confirm(`לאפס את ה-session של "${r.agent_name}"?`)) {
reset.mutate(r.agent_id);
}
}}
>
אפס
</Button>
</div>
</div>
);
})}
</div>
)}
</CardContent>
<RunLogDialog run={logRun} onClose={() => setLogRun(null)} />
</Card>
);
}
export default function OperationsPage() {
const { data, isLoading, error } = useOperations();
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">תפעול</span>
</nav>
<h1 className="text-navy mb-0">תפעול מה רץ ברקע</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
כל מה שמוריד ומנתח אוטומטית: שירותי-המארח, משימות-התזמון, ותורי-העבודה.
ניתן להפעיל-מחדש / לעצור / להריץ-עכשיו כל תהליך. מתרענן כל 5 שניות.
</p>
</header>
{error ? (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-5 text-sm text-destructive">
שגיאה בטעינת מצב התפעול: {String(error)}
</CardContent>
</Card>
) : isLoading || !data ? (
<div className="space-y-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
) : (
<>
<LiveAgentsPanel />
<ServicesPanel data={data} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<PipelineCard
title="אחזור פסיקה (X13)"
desc="הורדה אוטומטית מנט-המשפט / פורטל-העליון → קורפוס"
>
<UniformStats p={data.pipelines.court_fetch} />
</PipelineCard>
<PipelineCard
title="חילוץ מטא-דאטה"
desc="Gemini Flash — שם/תקציר/תגיות לכל פסיקה"
>
<UniformStats p={data.pipelines.metadata_extraction} />
</PipelineCard>
<PipelineCard
title="חילוץ הלכות"
desc="Claude — הלכות מכל פסיקה (→ אישור יו״ר)"
>
<UniformStats p={data.pipelines.halacha_extraction} />
</PipelineCard>
<PipelineCard
title="העשרת יומונים (רדאר)"
desc="Sonnet — תקציר/תגיות/קישור-לפסיקה לכל יומון"
>
<UniformStats p={data.pipelines.digests} />
<p className="text-[0.72rem] text-ink-muted">
{data.pipelines.digests.linked}/{data.pipelines.digests.total} מקושרים
לפסיקה
</p>
</PipelineCard>
<PipelineCard
title="אישור הלכות (שער יו״ר)"
desc="הלכות שחולצו, ממתינות להכרעת דפנה ב-/approvals — שער-אנושי, לא תהליך"
>
<StatusRow by={data.pipelines.halacha_review.by_status} />
</PipelineCard>
<PipelineCard
title="פסיקה חסרה"
desc="פערים בקורפוס — נסגרים אוטומטית כשנקלטים"
>
<StatusRow by={data.pipelines.missing_precedents.by_status} />
</PipelineCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-sm font-semibold mb-2">
אחזורים אחרונים (תור)
</h3>
<div className="space-y-1.5">
{data.pipelines.court_fetch.recent.map((j) => (
<div
key={j.case_number_norm}
className="flex items-start justify-between gap-2 text-[0.78rem] border-b border-rule-soft pb-1.5 last:border-0"
>
<div className="min-w-0">
<StatusBadge value={j.status} />
<span className="text-navy ms-2">
{j.citation_raw?.slice(0, 48) || j.case_number_norm}
</span>
{j.error ? (
<div className="text-ink-muted text-[0.68rem] truncate">
{j.error.slice(0, 80)}
</div>
) : null}
</div>
<span className="text-ink-muted text-[0.66rem] shrink-0" dir="ltr">
{(j.tier || "").slice(0, 7)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-sm font-semibold mb-2">
נקלטו לאחרונה (מבתי-משפט)
</h3>
<div className="space-y-1.5">
{data.pipelines.ingested_recent.map((r) => (
<div
key={r.case_number}
className="text-[0.78rem] text-navy border-b border-rule-soft pb-1.5 last:border-0 truncate"
>
{r.case_number}
<span className="text-ink-muted text-[0.66rem] ms-2" dir="ltr">
{r.created_at?.slice(0, 16).replace("T", " ")}
</span>
</div>
))}
{data.pipelines.ingested_recent.length === 0 ? (
<p className="text-ink-muted text-sm">עדיין אין.</p>
) : null}
</div>
</CardContent>
</Card>
</div>
</>
)}
</section>
</AppShell>
);
}