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

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:
soroush.asadi
2026-06-07 05:24:47 +03:30
parent d56bcf1b23
commit fca6bcac53
4 changed files with 100 additions and 6 deletions
@@ -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 {
+30 -4
View File
@@ -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>
);
}
+25 -2
View File
@@ -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>