Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants. Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
7.5 KiB
TypeScript
227 lines
7.5 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||
import { api, ApiError, type ServiceEntry, type ServiceStatus } from "@/lib/api";
|
||
import {
|
||
AdminPageHeader,
|
||
Banner,
|
||
Button,
|
||
EmptyState,
|
||
Modal,
|
||
SelectField,
|
||
StatusBadge,
|
||
TextField,
|
||
} from "@/components/admin/controls";
|
||
|
||
const STATUS_OPTIONS: { value: ServiceStatus; label: string }[] = [
|
||
{ value: "active", label: "فعال" },
|
||
{ value: "inactive", label: "غیرفعال" },
|
||
{ value: "maintenance", label: "در حال تعمیر" },
|
||
];
|
||
|
||
export default function AdminServicesPage() {
|
||
const [services, setServices] = useState<ServiceEntry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
const [open, setOpen] = useState(false);
|
||
const [busyId, setBusyId] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const page = await api.services.list(1, 100);
|
||
setServices(page.items);
|
||
} catch (e) {
|
||
setError(e instanceof ApiError ? e.message : "خطا در بارگذاری سرویسها");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
const changeStatus = async (svc: ServiceEntry, status: ServiceStatus) => {
|
||
if (status === svc.status) return;
|
||
setBusyId(svc.id);
|
||
setError("");
|
||
try {
|
||
await api.services.updateStatus(svc.id, status);
|
||
await load();
|
||
} catch (e) {
|
||
setError(e instanceof ApiError ? e.message : "تغییر وضعیت ناموفق بود");
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<AdminPageHeader
|
||
title="سرویسها"
|
||
subtitle="ثبت و مدیریت وضعیت سرویسهای پلتفرم"
|
||
action={<Button onClick={() => setOpen(true)}>ثبت سرویس</Button>}
|
||
/>
|
||
|
||
{error && <Banner variant="error">{error}</Banner>}
|
||
|
||
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
|
||
{loading ? (
|
||
<EmptyState message="در حال بارگذاری..." />
|
||
) : services.length === 0 ? (
|
||
<EmptyState message="هنوز سرویسی ثبت نشده است." />
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[720px] text-sm">
|
||
<thead className="bg-gray-50 text-right">
|
||
<tr>
|
||
<th className="px-4 py-3 font-semibold text-secondary">نام</th>
|
||
<th className="px-4 py-3 font-semibold text-secondary">کلید</th>
|
||
<th className="px-4 py-3 font-semibold text-secondary">آدرس</th>
|
||
<th className="px-4 py-3 font-semibold text-secondary">وضعیت</th>
|
||
<th className="px-4 py-3 font-semibold text-secondary">تغییر وضعیت</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{services.map((s) => (
|
||
<tr key={s.id} className="border-t border-gray-100">
|
||
<td className="px-4 py-3 font-medium text-secondary">{s.name}</td>
|
||
<td className="px-4 py-3 font-mono text-gray-600" dir="ltr">
|
||
{s.service_key}
|
||
</td>
|
||
<td className="px-4 py-3 font-mono text-gray-500" dir="ltr">
|
||
{s.base_url || "—"}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<StatusBadge status={s.status} />
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<select
|
||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs focus:border-primary focus:outline-none disabled:opacity-50"
|
||
value={s.status}
|
||
disabled={busyId === s.id}
|
||
onChange={(e) => changeStatus(s, e.target.value as ServiceStatus)}
|
||
>
|
||
{STATUS_OPTIONS.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<CreateServiceModal
|
||
open={open}
|
||
onClose={() => setOpen(false)}
|
||
onCreated={() => {
|
||
setOpen(false);
|
||
void load();
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CreateServiceModal({
|
||
open,
|
||
onClose,
|
||
onCreated,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onCreated: () => void;
|
||
}) {
|
||
const [serviceKey, setServiceKey] = useState("");
|
||
const [name, setName] = useState("");
|
||
const [baseUrl, setBaseUrl] = useState("");
|
||
const [healthUrl, setHealthUrl] = useState("");
|
||
const [status, setStatus] = useState<ServiceStatus>("active");
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
const handleSubmit = async (e: FormEvent) => {
|
||
e.preventDefault();
|
||
setError("");
|
||
setSaving(true);
|
||
try {
|
||
await api.services.create({
|
||
service_key: serviceKey.trim(),
|
||
name: name.trim(),
|
||
base_url: baseUrl.trim() || null,
|
||
health_check_url: healthUrl.trim() || null,
|
||
status,
|
||
});
|
||
setServiceKey("");
|
||
setName("");
|
||
setBaseUrl("");
|
||
setHealthUrl("");
|
||
onCreated();
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "ثبت سرویس ناموفق بود");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal open={open} title="ثبت سرویس جدید" onClose={onClose}>
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<TextField
|
||
label="کلید سرویس"
|
||
required
|
||
dir="ltr"
|
||
className="font-mono"
|
||
value={serviceKey}
|
||
onValue={setServiceKey}
|
||
disabled={saving}
|
||
placeholder="sms-service"
|
||
/>
|
||
<TextField label="نام" required value={name} onValue={setName} disabled={saving} />
|
||
<TextField
|
||
label="آدرس پایه (اختیاری)"
|
||
dir="ltr"
|
||
className="font-mono"
|
||
value={baseUrl}
|
||
onValue={setBaseUrl}
|
||
disabled={saving}
|
||
placeholder="http://sms-service:9000"
|
||
/>
|
||
<TextField
|
||
label="آدرس health check (اختیاری)"
|
||
dir="ltr"
|
||
className="font-mono"
|
||
value={healthUrl}
|
||
onValue={setHealthUrl}
|
||
disabled={saving}
|
||
placeholder="http://sms-service:9000/health"
|
||
/>
|
||
<SelectField
|
||
label="وضعیت"
|
||
value={status}
|
||
onValue={(v) => setStatus(v as ServiceStatus)}
|
||
options={STATUS_OPTIONS}
|
||
disabled={saving}
|
||
/>
|
||
{error && <Banner variant="error">{error}</Banner>}
|
||
<div className="flex justify-end gap-3 pt-2">
|
||
<Button variant="outline" onClick={onClose} type="button">
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" loading={saving} loadingText="در حال ثبت...">
|
||
ثبت سرویس
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|