feat(#41): admin/renders pagination + user name link + output + project name
Build backend images / build content-svc (push) Failing after 1m1s
Build backend images / build file-svc (push) Failing after 56s
Build backend images / build gateway (push) Failing after 49s
Build backend images / build identity-svc (push) Failing after 50s
Build backend images / build notification-svc (push) Failing after 49s
Build backend images / build render-svc (push) Failing after 1m2s
Build backend images / build studio-svc (push) Failing after 47s
Build backend images / build content-svc (push) Failing after 1m1s
Build backend images / build file-svc (push) Failing after 56s
Build backend images / build gateway (push) Failing after 49s
Build backend images / build identity-svc (push) Failing after 50s
Build backend images / build notification-svc (push) Failing after 49s
Build backend images / build render-svc (push) Failing after 1m2s
Build backend images / build studio-svc (push) Failing after 47s
render-svc admin-renders enriches jobs with user_name/email (cross-schema lookup to identity.users). Page adds prev/next pagination (page_size 30). Table adds User column (name → /admin/users?q=email) and Output column (export → /admin/exports), and shows project_name. Verified: 21 jobs, paginated, names resolved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,5 +41,47 @@ func (s *Store) ListAllJobs(ctx context.Context, status string, page, pageSize i
|
||||
}
|
||||
defer rows.Close()
|
||||
jobs, err := scanJobs(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.attachUserInfo(ctx, jobs)
|
||||
return jobs, total, err
|
||||
}
|
||||
|
||||
// attachUserInfo fills UserName/UserEmail on each job via one cross-schema lookup
|
||||
// against identity.users (same Postgres DB). Best-effort — failures leave names blank.
|
||||
func (s *Store) attachUserInfo(ctx context.Context, jobs []*models.RenderJob) {
|
||||
if len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(jobs))
|
||||
seen := map[string]bool{}
|
||||
for _, j := range jobs {
|
||||
id := j.UserID.String()
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id::text, COALESCE(full_name,''), COALESCE(email,'') FROM identity.users WHERE id = ANY($1)`,
|
||||
ids)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type ui struct{ name, email string }
|
||||
m := map[string]ui{}
|
||||
for rows.Next() {
|
||||
var id, name, email string
|
||||
if rows.Scan(&id, &name, &email) == nil {
|
||||
m[id] = ui{name, email}
|
||||
}
|
||||
}
|
||||
for _, j := range jobs {
|
||||
if info, ok := m[j.UserID.String()]; ok {
|
||||
j.UserName = info.name
|
||||
j.UserEmail = info.email
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,9 @@ type RenderJob struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Admin-list enrichment (set by the handler via a cross-schema lookup, not scanned).
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
}
|
||||
|
||||
type FrameJob struct {
|
||||
|
||||
@@ -10,6 +10,9 @@ export type V2RenderJob = {
|
||||
id: string;
|
||||
saved_project_id: string;
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
user_email?: string;
|
||||
project_name?: string | null;
|
||||
status: string;
|
||||
step: string;
|
||||
progress: number;
|
||||
@@ -17,6 +20,7 @@ export type V2RenderJob = {
|
||||
resolution: string;
|
||||
frame_rate: number;
|
||||
node_id: string | null;
|
||||
export_id?: string | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -24,20 +28,26 @@ export type V2RenderJob = {
|
||||
|
||||
interface V2RenderList {
|
||||
data: V2RenderJob[];
|
||||
meta?: { total?: number };
|
||||
meta?: { total?: number; page?: number; page_size?: number; has_more?: boolean };
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export default async function AdminRendersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { step?: string };
|
||||
searchParams: { step?: string; page?: string };
|
||||
}) {
|
||||
const step = searchParams.step ?? "";
|
||||
const page = Math.max(1, Number(searchParams.page) || 1);
|
||||
// Admin endpoint → all users' jobs (not just the caller's).
|
||||
const qs = step ? `?status=${step}&page_size=50` : "?page_size=50";
|
||||
const data = await adminGet<V2RenderList>(`/v1/admin-renders${qs}`);
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) });
|
||||
if (step) params.set("status", step);
|
||||
const data = await adminGet<V2RenderList>(`/v1/admin-renders?${params}`);
|
||||
const jobs = data?.data ?? [];
|
||||
const total = data?.meta?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const stepQs = step ? `&step=${step}` : "";
|
||||
const t = await getTranslations("auto.appAdminRendersPage");
|
||||
|
||||
const steps = ["Queued", "Preparing", "Rendering", "Uploading", "Done", "Failed", "Cancelled"];
|
||||
@@ -88,6 +98,22 @@ export default async function AdminRendersPage({
|
||||
</div>
|
||||
|
||||
<RenderQueueTable jobs={jobs} />
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 pt-2 text-sm">
|
||||
{page > 1 ? (
|
||||
<a href={`/admin/renders?page=${page - 1}${stepQs}`} className="rounded-lg border border-[#262b40] px-3 py-1.5 text-gray-300 hover:bg-[#161a2e]">قبلی</a>
|
||||
) : (
|
||||
<span className="rounded-lg border border-[#1a1d2e] px-3 py-1.5 text-gray-600">قبلی</span>
|
||||
)}
|
||||
<span className="text-gray-400">صفحهٔ {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")}</span>
|
||||
{page < totalPages ? (
|
||||
<a href={`/admin/renders?page=${page + 1}${stepQs}`} className="rounded-lg border border-[#262b40] px-3 py-1.5 text-gray-300 hover:bg-[#161a2e]">بعدی</a>
|
||||
) : (
|
||||
<span className="rounded-lg border border-[#1a1d2e] px-3 py-1.5 text-gray-600">بعدی</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,10 +75,12 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
||||
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">{t("colJobId")}</th>
|
||||
<th className="px-4 py-3">{t("colProject")}</th>
|
||||
<th className="px-4 py-3">کاربر</th>
|
||||
<th className="px-4 py-3">{t("colStep")}</th>
|
||||
<th className="px-4 py-3">{t("colProgress")}</th>
|
||||
<th className="px-4 py-3">{t("colQuality")}</th>
|
||||
<th className="px-4 py-3">{t("colNode")}</th>
|
||||
<th className="px-4 py-3">خروجی</th>
|
||||
<th className="px-4 py-3">{t("colCreated")}</th>
|
||||
<th className="px-4 py-3">{t("colActions")}</th>
|
||||
</tr>
|
||||
@@ -94,8 +96,17 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-gray-400">
|
||||
{job.id.slice(0, 12)}…
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-gray-500">
|
||||
{job.saved_project_id.slice(0, 12)}…
|
||||
<td className="px-4 py-3 text-xs text-gray-400">
|
||||
{job.project_name?.trim() || job.saved_project_id.slice(0, 8) + "…"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
<a
|
||||
href={`/admin/users?q=${encodeURIComponent(job.user_email || job.user_id)}`}
|
||||
className="text-indigo-300 hover:underline"
|
||||
title={job.user_email || job.user_id}
|
||||
>
|
||||
{job.user_name?.trim() || job.user_email || job.user_id.slice(0, 8) + "…"}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${stepColor}`}>
|
||||
@@ -126,6 +137,18 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-gray-500">
|
||||
{job.node_id ? job.node_id.slice(0, 8) + "…" : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{job.export_id ? (
|
||||
<a
|
||||
href={`/admin/exports?q=${encodeURIComponent(job.export_id)}`}
|
||||
className="inline-flex items-center gap-1 rounded border border-emerald-500/30 px-2 py-1 text-emerald-300 hover:bg-emerald-500/10"
|
||||
>
|
||||
▶ مشاهده
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">
|
||||
{relativeTime(job.created_at)}
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user