نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { Badge, Button, cx } from "@agy/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const NAV_ITEMS: { href: string; label: string; hint: string }[] = [
|
||||
{ href: "/dashboard", label: "داشبورد", hint: "نمای کلی سازمان" },
|
||||
{ href: "/manager", label: "مدیر AI", hint: "گفتگو با مدیر" },
|
||||
{ href: "/workforce", label: "نیروی کار AI", hint: "کارمندها" },
|
||||
{ href: "/projects", label: "پروژهها", hint: "Git و محیط اجرا" },
|
||||
{ href: "/tasks", label: "وظایف", hint: "وضعیت کارها" },
|
||||
{ href: "/traces", label: "ردیابی", hint: "اجراها و رویدادها" },
|
||||
{ href: "/knowledge", label: "دانش سازمانی", hint: "منابع و اسناد" },
|
||||
{ href: "/billing", label: "مصرف و پرداخت", hint: "اعتبار و فاکتور" },
|
||||
{ href: "/settings", label: "تنظیمات", hint: "سازمان و اعضا" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Command/search over the navigation.
|
||||
*
|
||||
* Deliberately client-side: the app has a fixed set of destinations, so
|
||||
* filtering them needs no round trip and works while offline.
|
||||
*/
|
||||
function CommandSearch() {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const matches = React.useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return [];
|
||||
return NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(needle) ||
|
||||
item.hint.toLowerCase().includes(needle) ||
|
||||
item.href.includes(needle),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
const go = (href: string) => {
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative hidden md:block">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="جستوجو…"
|
||||
aria-label="جستوجو در بخشها"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && matches.length > 0) go(matches[0].href);
|
||||
if (event.key === "Escape") setQuery("");
|
||||
}}
|
||||
className="h-9 w-56 rounded-lg border border-surface-border bg-surface-overlay px-3 text-xs text-slate-200 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
/>
|
||||
{open && query.trim() !== "" && (
|
||||
<div className="absolute start-0 top-11 z-40 w-72 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
|
||||
{matches.length === 0 ? (
|
||||
<p className="px-2 py-2 text-xs text-slate-400">نتیجهای پیدا نشد.</p>
|
||||
) : (
|
||||
matches.map((item) => (
|
||||
<button
|
||||
key={item.href}
|
||||
type="button"
|
||||
onMouseDown={() => go(item.href)}
|
||||
className="flex w-full flex-col rounded-md px-2 py-1.5 text-start hover:bg-surface-raised"
|
||||
>
|
||||
<span className="text-xs text-slate-200">{item.label}</span>
|
||||
<span className="text-xs text-slate-500">{item.hint}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Credit badge — reads the organization's real balance, never a placeholder. */
|
||||
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
|
||||
const [balance, setBalance] = React.useState<number | null>(null);
|
||||
const [low, setLow] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!organizationId) {
|
||||
setBalance(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api
|
||||
.billingSummary(organizationId)
|
||||
.then((summary) => {
|
||||
if (cancelled) return;
|
||||
setBalance(summary.balance);
|
||||
setLow(summary.low_balance);
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed balance must not break the shell; show it as unknown.
|
||||
if (!cancelled) setBalance(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [organizationId]);
|
||||
|
||||
if (balance === null) return <Badge tone="neutral">اعتبار: —</Badge>;
|
||||
return (
|
||||
<Badge tone={low ? "warning" : "success"}>
|
||||
اعتبار: {balance.toFixed(2)} {low ? "(کم)" : ""}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, organizations, activeOrganization, setActiveOrganization, logout } = useSession();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-surface">
|
||||
<aside className="hidden w-64 shrink-0 flex-col border-l border-surface-border bg-surface-raised lg:flex">
|
||||
<div className="flex h-16 items-center gap-2 border-b border-surface-border px-5">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent text-xs font-medium text-white">
|
||||
AG
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-100">AGY Web</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 p-3">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cx(
|
||||
"flex flex-col rounded-lg px-3 py-2 transition-colors",
|
||||
active
|
||||
? "bg-accent-soft text-slate-100"
|
||||
: "text-slate-400 hover:bg-surface-overlay hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{item.label}</span>
|
||||
<span className="text-xs text-slate-500">{item.hint}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{user?.is_platform_owner && (
|
||||
<>
|
||||
<div className="my-2 border-t border-surface-border" />
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cx(
|
||||
"flex flex-col rounded-lg px-3 py-2 transition-colors",
|
||||
pathname.startsWith("/admin")
|
||||
? "bg-accent-soft text-slate-100"
|
||||
: "text-slate-400 hover:bg-surface-overlay hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">کنسول مالک سامانه</span>
|
||||
<span className="text-xs text-slate-500">Provider، مدل، تعرفه</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-surface-border p-3">
|
||||
<p className="truncate text-xs text-slate-400">{user?.email}</p>
|
||||
<Button variant="ghost" size="sm" className="mt-2 w-full" onClick={handleLogout}>
|
||||
خروج
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-16 items-center justify-between gap-3 border-b border-surface-border bg-surface-raised px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{activeOrganization?.name ?? "بدون سازمان"}
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-10 z-40 w-64 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
|
||||
{organizations.length === 0 && (
|
||||
<p className="px-2 py-2 text-xs text-slate-400">هنوز سازمانی نساختهاید.</p>
|
||||
)}
|
||||
{organizations.map((org) => (
|
||||
<button
|
||||
key={org.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveOrganization(org.id);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={cx(
|
||||
"flex w-full items-center justify-between rounded-md px-2 py-2 text-right text-xs",
|
||||
org.id === activeOrganization?.id
|
||||
? "bg-accent-soft text-slate-100"
|
||||
: "text-slate-300 hover:bg-surface-raised",
|
||||
)}
|
||||
>
|
||||
<span>{org.name}</span>
|
||||
<span className="text-slate-500">{org.role}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-surface-border" />
|
||||
<Link
|
||||
href="/onboarding"
|
||||
className="block rounded-md px-2 py-2 text-xs text-slate-300 hover:bg-surface-raised"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
ساخت سازمان جدید
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<CreditBadge organizationId={activeOrganization?.id} />
|
||||
</span>
|
||||
<CommandSearch />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserMenuOpen((open) => !open)}
|
||||
aria-expanded={userMenuOpen}
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-surface-overlay"
|
||||
>
|
||||
<span className="hidden text-xs text-slate-300 sm:block">
|
||||
{user?.name || user?.email}
|
||||
</span>
|
||||
{user?.is_platform_owner && <Badge tone="info">مالک سامانه</Badge>}
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<div className="absolute end-0 top-11 z-40 w-56 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="truncate text-xs text-slate-200">
|
||||
{user?.name || "کاربر"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-500">{user?.email}</p>
|
||||
</div>
|
||||
<div className="my-1 border-t border-surface-border" />
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="block rounded-md px-2 py-1.5 text-xs text-slate-300 hover:bg-surface-raised"
|
||||
>
|
||||
تنظیمات سازمان
|
||||
</Link>
|
||||
{user?.is_platform_owner && (
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="block rounded-md px-2 py-1.5 text-xs text-slate-300 hover:bg-surface-raised"
|
||||
>
|
||||
کنسول مالک سامانه
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="mt-1 block w-full rounded-md px-2 py-1.5 text-start text-xs text-danger hover:bg-surface-raised"
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="min-w-0 flex-1 p-5">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardHint, CardTitle } from "@agy/ui";
|
||||
|
||||
/**
|
||||
* Placeholder for a page whose backend phase has not landed yet.
|
||||
*
|
||||
* It is deliberately explicit about *what* will live here rather than showing a
|
||||
* vague "coming soon" — a reader can tell whether the page is missing or the
|
||||
* feature is.
|
||||
*/
|
||||
export function PlaceholderPage({
|
||||
title,
|
||||
description,
|
||||
planned,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
planned: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">{title}</h1>
|
||||
<p className="text-sm text-slate-400">{description}</p>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardTitle>در این صفحه چه چیزی ساخته میشود</CardTitle>
|
||||
<CardHint>
|
||||
این بخش در فاز بعدی پیادهسازی میشود. فهرست زیر قرارداد نهایی این صفحه است.
|
||||
</CardHint>
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{planned.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-xs text-slate-300">
|
||||
<span aria-hidden className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Sandbox panel for a project.
|
||||
*
|
||||
* Two things this UI deliberately does NOT do:
|
||||
*
|
||||
* 1. It never sends a timeout or an output cap. Those are resolved server-side
|
||||
* from policy (project -> organization -> platform) and clamped, so a
|
||||
* tampered client cannot widen them.
|
||||
* 2. It shows no shell/exec affordance for the platform host. The only execution
|
||||
* surface is a workspace session, which is isolated and metered.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import type {
|
||||
SandboxCommandRun,
|
||||
SandboxResourceLimit,
|
||||
SandboxSession,
|
||||
SandboxTemplate,
|
||||
SandboxWorkspace,
|
||||
} from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
Stat,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
const STATUS_TONES = {
|
||||
ready: "success",
|
||||
running: "success",
|
||||
pending: "warning",
|
||||
starting: "warning",
|
||||
paused: "neutral",
|
||||
stopped: "neutral",
|
||||
destroyed: "danger",
|
||||
failed: "danger",
|
||||
} as const;
|
||||
|
||||
function toneFor(status: string) {
|
||||
return STATUS_TONES[status as keyof typeof STATUS_TONES] ?? "neutral";
|
||||
}
|
||||
|
||||
function humanBytes(bytes: number): string {
|
||||
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} مگابایت`;
|
||||
if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} کیلوبایت`;
|
||||
return `${bytes} بایت`;
|
||||
}
|
||||
|
||||
export function SandboxPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
|
||||
const [templates, setTemplates] = React.useState<SandboxTemplate[]>([]);
|
||||
const [workspaces, setWorkspaces] = React.useState<SandboxWorkspace[]>([]);
|
||||
const [limits, setLimits] = React.useState<SandboxResourceLimit | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [templateId, setTemplateId] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [pendingDestroy, setPendingDestroy] = React.useState<SandboxWorkspace | null>(null);
|
||||
const [destroying, setDestroying] = React.useState(false);
|
||||
|
||||
const [activeWorkspace, setActiveWorkspace] = React.useState<SandboxWorkspace | null>(null);
|
||||
const [session, setSession] = React.useState<SandboxSession | null>(null);
|
||||
const [starting, setStarting] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextTemplates, nextWorkspaces, nextLimits] = await Promise.all([
|
||||
api.sandboxTemplates(orgId),
|
||||
api.sandboxWorkspaces(orgId, projectId),
|
||||
api.sandboxLimits(orgId, projectId),
|
||||
]);
|
||||
setTemplates(nextTemplates);
|
||||
setWorkspaces(nextWorkspaces);
|
||||
setLimits(nextLimits);
|
||||
setTemplateId((current) => current || nextTemplates[0]?.id || "");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت اطلاعات sandbox ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, projectId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCreateWorkspace = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!templateId) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.sandboxCreateWorkspace(orgId, {
|
||||
project_id: projectId,
|
||||
template_id: templateId,
|
||||
});
|
||||
setWorkspaces((current) => [created, ...current]);
|
||||
setActiveWorkspace(created);
|
||||
setSession(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ساخت محیط ایزوله ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmDestroy = async () => {
|
||||
if (!pendingDestroy) return;
|
||||
setDestroying(true);
|
||||
try {
|
||||
await api.sandboxDestroyWorkspace(orgId, pendingDestroy.id);
|
||||
setWorkspaces((current) => current.filter((w) => w.id !== pendingDestroy.id));
|
||||
if (activeWorkspace?.id === pendingDestroy.id) {
|
||||
setActiveWorkspace(null);
|
||||
setSession(null);
|
||||
}
|
||||
setPendingDestroy(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "حذف محیط ناموفق بود.");
|
||||
setPendingDestroy(null);
|
||||
} finally {
|
||||
setDestroying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onStartSession = async (workspace: SandboxWorkspace) => {
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
setSession(await api.sandboxStartSession(orgId, workspace.id));
|
||||
setActiveWorkspace(workspace);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "شروع نشست ناموفق بود.");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت اطلاعات sandbox…" />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorState message={error} onRetry={load} />}
|
||||
|
||||
{limits && (
|
||||
<Card>
|
||||
<CardTitle>محدودیتهای مؤثر</CardTitle>
|
||||
<CardHint>
|
||||
این مقادیر از سیاست سازمان و سکو محاسبه و در سرور محدود میشوند. کلاینت نمیتواند
|
||||
آنها را تغییر دهد.
|
||||
</CardHint>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Stat label="CPU" value={`${limits.cpu} هسته`} />
|
||||
<Stat label="حافظه" value={`${limits.memory_mb} مگابایت`} />
|
||||
<Stat label="دیسک" value={`${limits.disk_mb} مگابایت`} />
|
||||
<Stat label="مهلت اجرا" value={`${limits.timeout_seconds} ثانیه`} />
|
||||
<Stat label="سقف خروجی" value={humanBytes(limits.max_output_bytes)} />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
سیاست شبکه: <span className="ltr-inline">{limits.network_policy}</span>
|
||||
{limits.network_policy === "deny_all" && " — دسترسی خروجی بسته است."}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardTitle>ساخت محیط ایزوله</CardTitle>
|
||||
<CardHint>
|
||||
کد در یک محیط جداگانه اجرا میشود؛ هیچ دستوری روی سرور میزبان اجرا نمیگردد. هر
|
||||
دستور در دفتر مصرف ثبت میشود.
|
||||
</CardHint>
|
||||
{templates.length === 0 ? (
|
||||
<p className="mt-3 text-xs text-slate-500">قالبی در دسترس نیست.</p>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={onCreateWorkspace}
|
||||
className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
label="قالب محیط"
|
||||
value={templateId}
|
||||
onChange={(e) => setTemplateId(e.target.value)}
|
||||
options={templates.map((template) => ({
|
||||
value: template.id,
|
||||
label: `${template.name} — ${template.image}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={creating}>
|
||||
ساخت محیط
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{templates.length > 0 && (
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{templates.find((t) => t.id === templateId)?.description ?? ""}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>محیطهای این پروژه</CardTitle>
|
||||
{workspaces.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-slate-500">هنوز محیطی ساخته نشده است.</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{workspaces.map((workspace) => {
|
||||
const isActive = activeWorkspace?.id === workspace.id;
|
||||
return (
|
||||
<li
|
||||
key={workspace.id}
|
||||
className={
|
||||
"flex flex-col gap-3 rounded-lg border px-3 py-3 sm:flex-row sm:items-center sm:justify-between " +
|
||||
(isActive
|
||||
? "border-accent/60 bg-surface-overlay"
|
||||
: "border-surface-border")
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={toneFor(workspace.status)}>{workspace.status}</Badge>
|
||||
<span className="ltr-inline truncate text-xs text-slate-400">
|
||||
{workspace.id}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
ارائهدهنده: <span className="ltr-inline">{workspace.provider}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isActive ? "ghost" : "secondary"}
|
||||
onClick={() => onStartSession(workspace)}
|
||||
loading={starting && !isActive}
|
||||
>
|
||||
{isActive ? "انتخابشده" : "انتخاب و شروع نشست"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => setPendingDestroy(workspace)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{activeWorkspace && session && (
|
||||
<SessionPanel
|
||||
orgId={orgId}
|
||||
session={session}
|
||||
onSessionChange={setSession}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDestroy !== null}
|
||||
title="حذف محیط ایزوله"
|
||||
description={
|
||||
pendingDestroy
|
||||
? "این محیط و همهٔ فایلهای داخل آن از بین میرود و نشستهای فعال بسته میشوند."
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={destroying ? "در حال حذف…" : "حذف کن"}
|
||||
onConfirm={onConfirmDestroy}
|
||||
onCancel={() => setPendingDestroy(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Session: commands, files, snapshot, preview */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function SessionPanel({
|
||||
orgId,
|
||||
session,
|
||||
onSessionChange,
|
||||
}: {
|
||||
orgId: string;
|
||||
session: SandboxSession;
|
||||
onSessionChange: (session: SandboxSession) => void;
|
||||
}) {
|
||||
const [command, setCommand] = React.useState("");
|
||||
const [runs, setRuns] = React.useState<SandboxCommandRun[]>([]);
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [filePath, setFilePath] = React.useState("");
|
||||
const [fileContent, setFileContent] = React.useState("");
|
||||
const [fileBusy, setFileBusy] = React.useState(false);
|
||||
const [fileNotice, setFileNotice] = React.useState<string | null>(null);
|
||||
|
||||
const [snapshotName, setSnapshotName] = React.useState("");
|
||||
const [snapshotBusy, setSnapshotBusy] = React.useState(false);
|
||||
|
||||
const [previewPort, setPreviewPort] = React.useState("8080");
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
||||
|
||||
const loadRuns = React.useCallback(async () => {
|
||||
try {
|
||||
setRuns(await api.sandboxCommands(orgId, session.id));
|
||||
} catch {
|
||||
/* history is a convenience; a failure here must not break the panel */
|
||||
}
|
||||
}, [orgId, session.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadRuns();
|
||||
}, [loadRuns]);
|
||||
|
||||
const onRun = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.sandboxRunCommand(orgId, session.id, trimmed);
|
||||
setRuns((current) => [...current, result]);
|
||||
setCommand("");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "اجرای دستور ناموفق بود.");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onStop = async () => {
|
||||
setStopping(true);
|
||||
setError(null);
|
||||
try {
|
||||
onSessionChange(await api.sandboxStopSession(orgId, session.id));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "پایان نشست ناموفق بود.");
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onReadFile = async () => {
|
||||
if (!filePath.trim()) return;
|
||||
setFileBusy(true);
|
||||
setFileNotice(null);
|
||||
try {
|
||||
const result = await api.sandboxReadFile(orgId, session.id, filePath.trim());
|
||||
setFileContent(result.content);
|
||||
setFileNotice(result.content === "" ? "فایل خالی است یا وجود ندارد." : null);
|
||||
} catch (err) {
|
||||
setFileNotice(err instanceof ApiError ? err.message : "خواندن فایل ناموفق بود.");
|
||||
} finally {
|
||||
setFileBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onWriteFile = async () => {
|
||||
if (!filePath.trim()) return;
|
||||
setFileBusy(true);
|
||||
setFileNotice(null);
|
||||
try {
|
||||
await api.sandboxWriteFile(orgId, session.id, filePath.trim(), fileContent);
|
||||
setFileNotice("فایل نوشته شد.");
|
||||
} catch (err) {
|
||||
setFileNotice(err instanceof ApiError ? err.message : "نوشتن فایل ناموفق بود.");
|
||||
} finally {
|
||||
setFileBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSnapshot = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!snapshotName.trim()) return;
|
||||
setSnapshotBusy(true);
|
||||
setFileNotice(null);
|
||||
try {
|
||||
const snapshot = await api.sandboxSnapshot(orgId, session.id, snapshotName.trim());
|
||||
setFileNotice(`نقطهٔ بازیابی «${snapshot.name}» ساخته شد.`);
|
||||
setSnapshotName("");
|
||||
} catch (err) {
|
||||
setFileNotice(err instanceof ApiError ? err.message : "ساخت نقطهٔ بازیابی ناموفق بود.");
|
||||
} finally {
|
||||
setSnapshotBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPreview = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const port = Number(previewPort);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
setFileNotice("شمارهٔ پورت باید عددی بین ۱ تا ۶۵۵۳۵ باشد.");
|
||||
return;
|
||||
}
|
||||
setFileBusy(true);
|
||||
setFileNotice(null);
|
||||
try {
|
||||
const result = await api.sandboxPreviewUrl(orgId, session.id, port);
|
||||
setPreviewUrl(result.url);
|
||||
} catch (err) {
|
||||
setFileNotice(err instanceof ApiError ? err.message : "ساخت آدرس پیشنمایش ناموفق بود.");
|
||||
} finally {
|
||||
setFileBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isStopped = session.status === "stopped" || session.status === "failed";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>نشست فعال</CardTitle>
|
||||
<CardHint>
|
||||
<span className="ltr-inline">{session.id}</span>
|
||||
</CardHint>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={toneFor(session.status)}>{session.status}</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onStop}
|
||||
loading={stopping}
|
||||
disabled={isStopped}
|
||||
>
|
||||
پایان نشست
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="خطا" message={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onRun} className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="دستور"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="مثلاً: python -m pytest -q"
|
||||
className="ltr-inline"
|
||||
disabled={isStopped}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={running} disabled={isStopped || !command.trim()}>
|
||||
اجرا
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{runs.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="rounded-lg border border-surface-border bg-surface-overlay p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code className="ltr-inline truncate text-xs text-slate-300">{run.command}</code>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{run.truncated && <Badge tone="warning">بریدهشده</Badge>}
|
||||
<Badge tone={run.exit_code === 0 ? "success" : "danger"}>
|
||||
<span className="ltr-inline">exit {run.exit_code ?? "—"}</span>
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-500">{run.duration_ms}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
{run.stdout && (
|
||||
<pre className="ltr-inline mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-all text-xs text-slate-300">
|
||||
{run.stdout}
|
||||
</pre>
|
||||
)}
|
||||
{run.stderr && (
|
||||
<pre className="ltr-inline mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-rose-300">
|
||||
{run.stderr}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-surface-border p-3">
|
||||
<p className="text-sm font-medium text-slate-200">فایلها</p>
|
||||
<Field
|
||||
label="مسیر"
|
||||
value={filePath}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
placeholder="/workspace/main.py"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onReadFile} loading={fileBusy}>
|
||||
خواندن
|
||||
</Button>
|
||||
<Button size="sm" onClick={onWriteFile} loading={fileBusy}>
|
||||
نوشتن
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
className="ltr-inline w-full rounded-lg border border-surface-border bg-surface-base px-3 py-2 font-mono text-xs text-slate-200 outline-none focus:border-accent"
|
||||
placeholder="محتوای فایل"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<form
|
||||
onSubmit={onSnapshot}
|
||||
className="flex flex-col gap-3 rounded-lg border border-surface-border p-3"
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-200">نقطهٔ بازیابی</p>
|
||||
<Field
|
||||
label="نام"
|
||||
value={snapshotName}
|
||||
onChange={(e) => setSnapshotName(e.target.value)}
|
||||
placeholder="before-refactor"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={snapshotBusy}
|
||||
disabled={!snapshotName.trim()}
|
||||
>
|
||||
ساخت نقطهٔ بازیابی
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
onSubmit={onPreview}
|
||||
className="flex flex-col gap-3 rounded-lg border border-surface-border p-3"
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-200">پیشنمایش</p>
|
||||
<Field
|
||||
label="پورت"
|
||||
inputMode="numeric"
|
||||
value={previewPort}
|
||||
onChange={(e) => setPreviewPort(e.target.value)}
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<div>
|
||||
<Button type="submit" size="sm" variant="secondary" loading={fileBusy}>
|
||||
ساخت آدرس
|
||||
</Button>
|
||||
</div>
|
||||
{previewUrl && (
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="ltr-inline truncate text-xs text-accent hover:underline"
|
||||
>
|
||||
{previewUrl}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fileNotice && <p className="mt-3 text-xs text-slate-400">{fileNotice}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user