update migrate

This commit is contained in:
2026-03-12 15:17:31 +00:00
parent 35117b7be9
commit 3beabcfe7f
13 changed files with 1660 additions and 436 deletions

View File

@@ -44,7 +44,7 @@ const links = computed<Record<string, any>>(() => {
...baseLinks, ...baseLinks,
{ {
href: "/admin/overview", href: "/admin/overview",
label: "Admin", label: "Admin Console",
icon: LayoutDashboard, icon: LayoutDashboard,
action: null, action: null,
className, className,

View File

@@ -6,18 +6,27 @@ import AppInput from "@/components/app/AppInput.vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminAdTemplateRow = any; type ListTemplatesResponse = Awaited<ReturnType<typeof rpcClient.listAdminAdTemplates>>;
type AdminAdTemplateRow = NonNullable<ListTemplatesResponse["templates"]>[number];
const formatOptions = ["pre-roll", "mid-roll", "post-roll"] as const;
const loading = ref(true); const loading = ref(true);
const submitting = ref(false); const submitting = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const actionError = ref<string | null>(null); const actionError = ref<string | null>(null);
const rows = ref<AdminAdTemplateRow[]>([]); const rows = ref<AdminAdTemplateRow[]>([]);
const total = ref(0);
const limit = ref(12);
const page = ref(1);
const selectedRow = ref<AdminAdTemplateRow | null>(null); const selectedRow = ref<AdminAdTemplateRow | null>(null);
const search = ref("");
const appliedSearch = ref("");
const ownerFilter = ref("");
const appliedOwnerFilter = ref("");
const createOpen = ref(false); const createOpen = ref(false);
const editOpen = ref(false); const editOpen = ref(false);
const deleteOpen = ref(false); const deleteOpen = ref(false);
const formatOptions = ["pre-roll", "mid-roll", "post-roll"];
const createForm = reactive({ const createForm = reactive({
userId: "", userId: "",
@@ -44,13 +53,43 @@ const editForm = reactive({
const canCreate = computed(() => createForm.userId.trim() && createForm.name.trim() && createForm.vastTagUrl.trim()); const canCreate = computed(() => createForm.userId.trim() && createForm.name.trim() && createForm.vastTagUrl.trim());
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.name.trim() && editForm.vastTagUrl.trim()); const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.name.trim() && editForm.vastTagUrl.trim());
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
const summary = computed(() => [
{ label: "Visible templates", value: rows.value.length },
{ label: "Active", value: rows.value.filter((row) => row.isActive).length },
{ label: "Default", value: rows.value.filter((row) => row.isDefault).length },
{ label: "Total records", value: total.value },
]);
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
return [
{ label: "Owner", value: selectedRow.value.ownerEmail || selectedRow.value.userId || "—" },
{ label: "Format", value: selectedRow.value.adFormat || "—" },
{ label: "Status", value: selectedRow.value.isActive ? "ACTIVE" : "INACTIVE" },
{ label: "Default", value: selectedRow.value.isDefault ? "YES" : "NO" },
{ label: "Duration", value: selectedRow.value.duration ? `${selectedRow.value.duration}s` : "—" },
{ label: "Created", value: formatDate(selectedRow.value.createdAt) },
];
});
const loadTemplates = async () => { const loadTemplates = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await rpcClient.listAdminAdTemplates({ page: 1, limit: 20 }); const response = await rpcClient.listAdminAdTemplates({
page: page.value,
limit: limit.value,
userId: appliedOwnerFilter.value.trim() || undefined,
search: appliedSearch.value.trim() || undefined,
});
rows.value = response.templates ?? []; rows.value = response.templates ?? [];
total.value = response.total ?? rows.value.length;
limit.value = response.limit ?? limit.value;
page.value = response.page ?? page.value;
if (selectedRow.value?.id) {
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
if (fresh) selectedRow.value = fresh;
}
} catch (err: any) { } catch (err: any) {
error.value = err?.message || "Failed to load admin ad templates"; error.value = err?.message || "Failed to load admin ad templates";
} finally { } finally {
@@ -73,10 +112,16 @@ const closeDialogs = () => {
createOpen.value = false; createOpen.value = false;
editOpen.value = false; editOpen.value = false;
deleteOpen.value = false; deleteOpen.value = false;
selectedRow.value = null;
actionError.value = null; actionError.value = null;
}; };
const applyFilters = async () => {
page.value = 1;
appliedSearch.value = search.value;
appliedOwnerFilter.value = ownerFilter.value;
await loadTemplates();
};
const openEditDialog = (row: AdminAdTemplateRow) => { const openEditDialog = (row: AdminAdTemplateRow) => {
selectedRow.value = row; selectedRow.value = row;
actionError.value = null; actionError.value = null;
@@ -140,7 +185,6 @@ const submitEdit = async () => {
isDefault: editForm.isDefault, isDefault: editForm.isDefault,
}); });
editOpen.value = false; editOpen.value = false;
selectedRow.value = null;
await loadTemplates(); await loadTemplates();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to update ad template"; actionError.value = err?.message || "Failed to update ad template";
@@ -157,6 +201,7 @@ const submitDelete = async () => {
await rpcClient.deleteAdminAdTemplate({ id: selectedRow.value.id }); await rpcClient.deleteAdminAdTemplate({ id: selectedRow.value.id });
deleteOpen.value = false; deleteOpen.value = false;
selectedRow.value = null; selectedRow.value = null;
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
await loadTemplates(); await loadTemplates();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to delete ad template"; actionError.value = err?.message || "Failed to delete ad template";
@@ -165,51 +210,125 @@ const submitDelete = async () => {
} }
}; };
const previousPage = async () => {
if (page.value <= 1) return;
page.value -= 1;
await loadTemplates();
};
const nextPage = async () => {
if (page.value >= totalPages.value) return;
page.value += 1;
await loadTemplates();
};
const formatDate = (value?: string) => {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
onMounted(loadTemplates); onMounted(loadTemplates);
</script> </script>
<template> <template>
<AdminSectionShell <AdminSectionShell
title="Admin Ad Templates" title="Admin Ad Templates"
description="Cross-user ad template management over admin gRPC service." description="Operate VAST templates as reusable assets with quick filtering, defaults and owner context."
eyebrow="Advertising"
:badge="`${total} total templates`"
> >
<div class="mb-4 flex justify-end"> <template #toolbar>
<AppButton size="sm" variant="secondary" @click="loadTemplates">Refresh</AppButton>
<AppButton size="sm" @click="actionError = null; createOpen = true">Create template</AppButton> <AppButton size="sm" @click="actionError = null; createOpen = true">Create template</AppButton>
</template>
<template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ item.label }}</div>
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
</div>
</template>
<template #aside>
<div class="space-y-5">
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected template</div>
<div v-if="selectedRow" class="space-y-4">
<div>
<div class="text-lg font-semibold text-white">{{ selectedRow.name }}</div>
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.id }}</div>
</div>
<div class="grid gap-3">
<div v-for="item in selectedMeta" :key="item.label" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</div>
<div class="mt-1 text-sm font-medium text-white">{{ item.value }}</div>
</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">VAST URL</div>
<div class="mt-2 break-all text-sm text-slate-200">{{ selectedRow.vastTagUrl }}</div>
</div>
<div class="grid gap-2">
<AppButton size="sm" @click="openEditDialog(selectedRow)">Edit template</AppButton>
<AppButton size="sm" variant="danger" @click="openDeleteDialog(selectedRow)">Delete template</AppButton>
</div>
</div>
<div v-else class="rounded-2xl border border-dashed border-white/15 px-4 py-5 text-sm leading-6 text-slate-400">
Select a template to inspect status, default flag and VAST source.
</div>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 rounded-2xl border border-slate-200 bg-slate-50/80 p-4 xl:grid-cols-[minmax(0,1fr)_220px_auto] xl:items-end">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Search</label>
<AppInput v-model="search" placeholder="Search template name" @enter="applyFilters" />
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Owner user ID</label>
<AppInput v-model="ownerFilter" placeholder="Optional owner id" @enter="applyFilters" />
</div>
<div class="flex items-center gap-2 xl:justify-end">
<AppButton size="sm" variant="ghost" @click="search = ''; ownerFilter = ''; appliedSearch = ''; appliedOwnerFilter = ''; loadTemplates()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div>
</div> </div>
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
{{ error }}
</div>
<div v-else class="overflow-x-auto"> <div v-else class="overflow-hidden rounded-2xl border border-slate-200">
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm"> <table class="min-w-full text-left text-sm">
<thead> <thead class="bg-slate-50/90 text-slate-500">
<tr class="border-b border-gray-200 text-gray-500"> <tr>
<th class="py-3 pr-4 font-medium">Name</th> <th class="px-4 py-3 font-semibold">Template</th>
<th class="py-3 pr-4 font-medium">Owner</th> <th class="px-4 py-3 font-semibold">Owner</th>
<th class="py-3 pr-4 font-medium">Format</th> <th class="px-4 py-3 font-semibold">Format</th>
<th class="py-3 pr-4 font-medium">Status</th> <th class="px-4 py-3 font-semibold">Status</th>
<th class="py-3 pr-4 font-medium">Default</th> <th class="px-4 py-3 font-semibold">Default</th>
<th class="py-3 pr-4 text-right font-medium">Actions</th> <th class="px-4 py-3 text-right font-semibold">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading" class="border-b border-gray-100"> <tr v-if="loading" class="border-t border-slate-200">
<td colspan="6" class="py-6 text-center text-gray-500">Loading ad templates...</td> <td colspan="6" class="px-4 py-10 text-center text-slate-500">Loading ad templates...</td>
</tr> </tr>
<tr v-else-if="rows.length === 0" class="border-b border-gray-100"> <tr v-else-if="rows.length === 0" class="border-t border-slate-200">
<td colspan="6" class="py-6 text-center text-gray-500">No ad templates found.</td> <td colspan="6" class="px-4 py-10 text-center text-slate-500">No templates matched the current filters.</td>
</tr> </tr>
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top"> <tr v-for="row in rows" :key="row.id" class="border-t border-slate-200 transition-colors hover:bg-slate-50/70" :class="selectedRow?.id === row.id ? 'bg-sky-50/60' : ''">
<td class="py-3 pr-4 text-gray-700"> <td class="px-4 py-3">
<div class="font-medium">{{ row.name }}</div> <button class="text-left" @click="selectedRow = row">
<div class="text-xs text-gray-500">{{ row.vastTagUrl }}</div> <div class="font-medium text-slate-900">{{ row.name }}</div>
<div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
</button>
</td> </td>
<td class="py-3 pr-4 text-gray-700">{{ row.ownerEmail || row.userId }}</td> <td class="px-4 py-3 text-slate-700">{{ row.ownerEmail || row.userId }}</td>
<td class="py-3 pr-4 text-gray-700">{{ row.adFormat || 'pre-roll' }}</td> <td class="px-4 py-3 text-slate-700">{{ row.adFormat || 'pre-roll' }}</td>
<td class="py-3 pr-4 text-gray-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td> <td class="px-4 py-3 text-slate-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td>
<td class="py-3 pr-4 text-gray-700">{{ row.isDefault ? 'YES' : 'NO' }}</td> <td class="px-4 py-3 text-slate-700">{{ row.isDefault ? 'YES' : 'NO' }}</td>
<td class="py-3 text-right"> <td class="px-4 py-3">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton> <AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton> <AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
@@ -219,6 +338,16 @@ onMounted(loadTemplates);
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex flex-col gap-3 border-t border-slate-200 bg-slate-50/70 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div class="text-xs font-medium uppercase tracking-[0.16em] text-slate-500">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
<div class="flex items-center gap-2">
<AppButton size="sm" variant="secondary" :disabled="page <= 1 || loading" @click="previousPage">Previous</AppButton>
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
</div>
</div>
</div>
</div>
</AdminSectionShell> </AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create ad template" maxWidthClass="max-w-2xl" @close="actionError = null"> <AppDialog v-model:visible="createOpen" title="Create ad template" maxWidthClass="max-w-2xl" @close="actionError = null">

View File

@@ -3,10 +3,11 @@ import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/app/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/app/AppDialog.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt"; import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminAgentRow = any; type ListAgentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminAgents>>;
type AdminAgentRow = NonNullable<ListAgentsResponse["agents"]>[number];
const loading = ref(true); const loading = ref(true);
const submitting = ref(false); const submitting = ref(false);
@@ -16,6 +17,27 @@ const rows = ref<AdminAgentRow[]>([]);
const selectedRow = ref<AdminAgentRow | null>(null); const selectedRow = ref<AdminAgentRow | null>(null);
const restartOpen = ref(false); const restartOpen = ref(false);
const updateOpen = ref(false); const updateOpen = ref(false);
let reloadAgentsTimer: ReturnType<typeof setTimeout> | null = null;
const summary = computed(() => [
{ label: "Agents", value: rows.value.length },
{ label: "Online", value: rows.value.filter((row) => matchesStatus(row.status, ["online", "active"])).length },
{ label: "Busy", value: rows.value.reduce((sum, row) => sum + Number(row.activeJobCount ?? 0), 0) },
{ label: "Total capacity", value: rows.value.reduce((sum, row) => sum + Number(row.capacity ?? 0), 0) },
]);
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
return [
{ label: "Status", value: selectedRow.value.status || "—" },
{ label: "Platform", value: selectedRow.value.platform || "—" },
{ label: "Version", value: selectedRow.value.version || "—" },
{ label: "Capacity", value: String(selectedRow.value.capacity ?? 0) },
{ label: "Active jobs", value: String(selectedRow.value.activeJobCount ?? 0) },
{ label: "Heartbeat", value: formatDate(selectedRow.value.lastHeartbeat) },
];
});
const matchesStatus = (value: string | undefined, candidates: string[]) => candidates.includes(String(value || "").toLowerCase());
const loadAgents = async () => { const loadAgents = async () => {
loading.value = true; loading.value = true;
@@ -23,6 +45,10 @@ const loadAgents = async () => {
try { try {
const response = await rpcClient.listAdminAgents(); const response = await rpcClient.listAdminAgents();
rows.value = response.agents ?? []; rows.value = response.agents ?? [];
if (selectedRow.value?.id) {
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
if (fresh) selectedRow.value = fresh;
}
} catch (err: any) { } catch (err: any) {
error.value = err?.message || "Failed to load admin agents"; error.value = err?.message || "Failed to load admin agents";
} finally { } finally {
@@ -33,10 +59,18 @@ const loadAgents = async () => {
const closeDialogs = () => { const closeDialogs = () => {
restartOpen.value = false; restartOpen.value = false;
updateOpen.value = false; updateOpen.value = false;
selectedRow.value = null;
actionError.value = null; actionError.value = null;
}; };
const scheduleAgentsReload = () => {
if (loading.value) return;
if (reloadAgentsTimer) clearTimeout(reloadAgentsTimer);
reloadAgentsTimer = setTimeout(() => {
reloadAgentsTimer = null;
loadAgents();
}, 300);
};
const openRestartDialog = (row: AdminAgentRow) => { const openRestartDialog = (row: AdminAgentRow) => {
selectedRow.value = row; selectedRow.value = row;
actionError.value = null; actionError.value = null;
@@ -56,7 +90,6 @@ const submitRestart = async () => {
try { try {
await rpcClient.restartAdminAgent({ id: selectedRow.value.id }); await rpcClient.restartAdminAgent({ id: selectedRow.value.id });
restartOpen.value = false; restartOpen.value = false;
selectedRow.value = null;
await loadAgents(); await loadAgents();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to restart agent"; actionError.value = err?.message || "Failed to restart agent";
@@ -72,7 +105,6 @@ const submitUpdate = async () => {
try { try {
await rpcClient.updateAdminAgent({ id: selectedRow.value.id }); await rpcClient.updateAdminAgent({ id: selectedRow.value.id });
updateOpen.value = false; updateOpen.value = false;
selectedRow.value = null;
await loadAgents(); await loadAgents();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to update agent"; actionError.value = err?.message || "Failed to update agent";
@@ -81,8 +113,27 @@ const submitUpdate = async () => {
} }
}; };
const formatDate = (value?: string) => {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const formatCpu = (value?: number) => `${Number(value ?? 0).toFixed(1)}%`;
const formatRam = (value?: number) => `${Number(value ?? 0).toFixed(1)} MB`;
const statusBadgeClass = (status?: string) => {
const normalized = String(status || "").toLowerCase();
if (["online", "active"].includes(normalized)) return "border-emerald-200 bg-emerald-50 text-emerald-700";
if (["busy", "updating"].includes(normalized)) return "border-amber-200 bg-amber-50 text-amber-700";
if (["offline", "error", "failed"].includes(normalized)) return "border-rose-200 bg-rose-50 text-rose-700";
return "border-slate-200 bg-slate-100 text-slate-700";
};
useAdminRuntimeMqtt(({ topic, payload }) => { useAdminRuntimeMqtt(({ topic, payload }) => {
if (topic !== "picpic/events" || payload?.type !== "agent_update") return; if (topic !== "picpic/events") return;
if (payload?.type === "agent_update") {
const update = payload.payload; const update = payload.payload;
if (!update?.id) return; if (!update?.id) return;
const row = rows.value.find((item) => item.id === update.id); const row = rows.value.find((item) => item.id === update.id);
@@ -97,6 +148,23 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
} else { } else {
loadAgents(); loadAgents();
} }
}
if (payload?.type === "resource_update") {
const update = payload.payload;
if (!update?.agent_id) return;
const row = rows.value.find((item) => item.id === update.agent_id);
if (row) {
row.cpu = update.cpu ?? row.cpu;
row.ram = update.ram ?? row.ram;
row.lastHeartbeat = new Date().toISOString();
row.status = row.status || "online";
}
}
if (payload?.type === "job_update") {
scheduleAgentsReload();
}
}); });
onMounted(loadAgents); onMounted(loadAgents);
@@ -105,49 +173,100 @@ onMounted(loadAgents);
<template> <template>
<AdminSectionShell <AdminSectionShell
title="Admin Agents" title="Admin Agents"
description="Connected render workers and command controls over admin gRPC service." description="Watch worker health, capacity and maintenance actions while staying on the current admin runtime transport."
eyebrow="Workers"
:badge="`${rows.length} agents connected`"
> >
<div class="mb-4 flex justify-end"> <template #toolbar>
<AppButton size="sm" variant="secondary" @click="loadAgents">Refresh agents</AppButton> <AppButton size="sm" variant="secondary" :loading="loading" @click="loadAgents">Refresh agents</AppButton>
</div> </template>
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <template #stats>
{{ error }} <div v-for="item in summary" :key="item.label" class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ item.label }}</div>
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
</div> </div>
</template>
<div v-else class="overflow-x-auto"> <template #aside>
<div class="space-y-5">
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected agent</div>
<div v-if="selectedRow" class="space-y-4">
<div>
<div class="text-lg font-semibold text-white">{{ selectedRow.name || 'Unnamed agent' }}</div>
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.id }}</div>
</div>
<div class="grid gap-3">
<div v-for="item in selectedMeta" :key="item.label" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</div>
<div class="mt-1 text-sm font-medium text-white">{{ item.value }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">CPU</div>
<div class="mt-1 text-sm font-medium text-white">{{ formatCpu(selectedRow.cpu) }}</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">RAM</div>
<div class="mt-1 text-sm font-medium text-white">{{ formatRam(selectedRow.ram) }}</div>
</div>
</div>
<div class="grid gap-2">
<AppButton size="sm" @click="openUpdateDialog(selectedRow)">Update agent</AppButton>
<AppButton size="sm" variant="danger" @click="openRestartDialog(selectedRow)">Restart agent</AppButton>
</div>
</div>
<div v-else class="rounded-2xl border border-dashed border-white/15 px-4 py-5 text-sm leading-6 text-slate-400">
Select an agent to inspect heartbeat, capacity and dispatch maintenance commands.
</div>
</div>
</template>
<div class="space-y-4">
<div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
<div v-else class="overflow-hidden rounded-2xl border border-slate-200">
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm"> <table class="min-w-full text-left text-sm">
<thead> <thead class="bg-slate-50/90 text-slate-500">
<tr class="border-b border-gray-200 text-gray-500"> <tr>
<th class="py-3 pr-4 font-medium">Agent</th> <th class="px-4 py-3 font-semibold">Agent</th>
<th class="py-3 pr-4 font-medium">Status</th> <th class="px-4 py-3 font-semibold">Status</th>
<th class="py-3 pr-4 font-medium">Platform</th> <th class="px-4 py-3 font-semibold text-right">Capacity</th>
<th class="py-3 pr-4 font-medium">Version</th> <th class="px-4 py-3 font-semibold text-right">Active jobs</th>
<th class="py-3 pr-4 font-medium">CPU</th> <th class="px-4 py-3 font-semibold text-right">CPU</th>
<th class="py-3 pr-4 font-medium">RAM</th> <th class="px-4 py-3 font-semibold text-right">RAM</th>
<th class="py-3 pr-4 font-medium">Heartbeat</th> <th class="px-4 py-3 font-semibold">Heartbeat</th>
<th class="py-3 pr-4 text-right font-medium">Actions</th> <th class="px-4 py-3 text-right font-semibold">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading" class="border-b border-gray-100"> <tr v-if="loading" class="border-t border-slate-200">
<td colspan="8" class="py-6 text-center text-gray-500">Loading agents...</td> <td colspan="8" class="px-4 py-10 text-center text-slate-500">Loading agents...</td>
</tr> </tr>
<tr v-else-if="rows.length === 0" class="border-b border-gray-100"> <tr v-else-if="rows.length === 0" class="border-t border-slate-200">
<td colspan="8" class="py-6 text-center text-gray-500">No agents connected.</td> <td colspan="8" class="px-4 py-10 text-center text-slate-500">No agents connected.</td>
</tr> </tr>
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top"> <tr v-for="row in rows" :key="row.id" class="border-t border-slate-200 transition-colors hover:bg-slate-50/70" :class="selectedRow?.id === row.id ? 'bg-sky-50/60' : ''">
<td class="py-3 pr-4 text-gray-700"> <td class="px-4 py-3">
<div class="font-medium">{{ row.name || row.id }}</div> <button class="text-left" @click="selectedRow = row">
<div class="text-xs text-gray-500">{{ row.id }}</div> <div class="font-medium text-slate-900">{{ row.name || 'Unnamed agent' }}</div>
<div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
<div class="mt-1 text-xs text-slate-500">{{ row.platform || '—' }} · {{ row.backend || '—' }} · {{ row.version || '—' }}</div>
</button>
</td> </td>
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td> <td class="px-4 py-3">
<td class="py-3 pr-4 text-gray-700">{{ row.platform || '—' }}</td> <span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="statusBadgeClass(row.status)">
<td class="py-3 pr-4 text-gray-700">{{ row.version || '' }}</td> {{ row.status || 'UNKNOWN' }}
<td class="py-3 pr-4 text-gray-700">{{ row.cpu ?? 0 }}</td> </span>
<td class="py-3 pr-4 text-gray-700">{{ row.ram ?? 0 }}</td> </td>
<td class="py-3 pr-4 text-gray-700">{{ row.lastHeartbeat ? new Date(row.lastHeartbeat).toLocaleString() : '—' }}</td> <td class="px-4 py-3 text-right text-slate-700">{{ row.capacity ?? 0 }}</td>
<td class="py-3 text-right"> <td class="px-4 py-3 text-right text-slate-700">{{ row.activeJobCount ?? 0 }}</td>
<td class="px-4 py-3 text-right text-slate-700">{{ formatCpu(row.cpu) }}</td>
<td class="px-4 py-3 text-right text-slate-700">{{ formatRam(row.ram) }}</td>
<td class="px-4 py-3 text-slate-500">{{ formatDate(row.lastHeartbeat) }}</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<AppButton size="sm" variant="secondary" @click="openUpdateDialog(row)">Update</AppButton> <AppButton size="sm" variant="secondary" @click="openUpdateDialog(row)">Update</AppButton>
<AppButton size="sm" variant="danger" @click="openRestartDialog(row)">Restart</AppButton> <AppButton size="sm" variant="danger" @click="openRestartDialog(row)">Restart</AppButton>
@@ -157,6 +276,8 @@ onMounted(loadAgents);
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
</AdminSectionShell> </AdminSectionShell>
<AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @close="actionError = null"> <AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @close="actionError = null">

View File

@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/app/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/app/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/app/AppInput.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminJobRow = any; type ListJobsResponse = Awaited<ReturnType<typeof rpcClient.listAdminJobs>>;
type AdminJobRow = NonNullable<ListJobsResponse["jobs"]>[number];
const loading = ref(true); const loading = ref(true);
const submitting = ref(false); const submitting = ref(false);
@@ -16,11 +17,13 @@ const actionError = ref<string | null>(null);
const rows = ref<AdminJobRow[]>([]); const rows = ref<AdminJobRow[]>([]);
const selectedRow = ref<AdminJobRow | null>(null); const selectedRow = ref<AdminJobRow | null>(null);
const selectedLogs = ref(""); const selectedLogs = ref("");
const activeAgentFilter = ref("");
const appliedAgentFilter = ref("");
const search = ref("");
const createOpen = ref(false); const createOpen = ref(false);
const logsOpen = ref(false); const logsOpen = ref(false);
const cancelOpen = ref(false); const cancelOpen = ref(false);
const retryOpen = ref(false); const retryOpen = ref(false);
const activeAgentFilter = ref("");
const createForm = reactive({ const createForm = reactive({
command: "", command: "",
@@ -42,14 +45,46 @@ const parseEnvText = (value: string) =>
if (separatorIndex === -1) return acc; if (separatorIndex === -1) return acc;
const key = line.slice(0, separatorIndex).trim(); const key = line.slice(0, separatorIndex).trim();
const val = line.slice(separatorIndex + 1).trim(); const val = line.slice(separatorIndex + 1).trim();
if (key) { if (key) acc[key] = val;
acc[key] = val;
}
return acc; return acc;
}, {}); }, {});
const hasEnv = computed(() => Object.keys(parseEnvText(createForm.envText)).length > 0); const hasEnv = computed(() => Object.keys(parseEnvText(createForm.envText)).length > 0);
const canCreate = computed(() => createForm.command.trim().length > 0); const canCreate = computed(() => createForm.command.trim().length > 0);
const filteredRows = computed(() => {
const keyword = search.value.trim().toLowerCase();
if (!keyword) return rows.value;
return rows.value.filter((row) => {
return [row.id, row.name, row.userId, row.agentId, row.status]
.map((value) => String(value || "").toLowerCase())
.some((value) => value.includes(keyword));
});
});
const summary = computed(() => [
{ label: "Visible jobs", value: filteredRows.value.length },
{ label: "Running", value: rows.value.filter((row) => matchesStatus(row.status, ["running", "processing"])).length },
{ label: "Queued", value: rows.value.filter((row) => matchesStatus(row.status, ["pending", "queued"])).length },
{ label: "Failures", value: rows.value.filter((row) => matchesStatus(row.status, ["failure", "failed", "cancelled"])).length },
]);
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
return [
{ label: "Status", value: selectedRow.value.status || "—" },
{ label: "Agent", value: selectedRow.value.agentId || "Unassigned" },
{ label: "Priority", value: String(selectedRow.value.priority ?? 0) },
{ label: "Progress", value: formatProgress(selectedRow.value.progress) },
{ label: "User", value: selectedRow.value.userId || "—" },
{ label: "Updated", value: formatDate(selectedRow.value.updatedAt) },
];
});
const matchesStatus = (value: string | undefined, candidates: string[]) => {
const normalized = String(value || "").toLowerCase();
return candidates.includes(normalized);
};
const isCancelable = (row?: AdminJobRow | null) => matchesStatus(row?.status, ["pending", "queued", "running", "processing"]);
const isRetryable = (row?: AdminJobRow | null) => matchesStatus(row?.status, ["failure", "failed", "cancelled"]);
const resetCreateForm = () => { const resetCreateForm = () => {
createForm.command = ""; createForm.command = "";
@@ -66,11 +101,15 @@ const closeDialogs = () => {
logsOpen.value = false; logsOpen.value = false;
cancelOpen.value = false; cancelOpen.value = false;
retryOpen.value = false; retryOpen.value = false;
selectedRow.value = null;
selectedLogs.value = "";
actionError.value = null; actionError.value = null;
}; };
const syncSelectedRow = () => {
if (!selectedRow.value?.id) return;
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
if (fresh) selectedRow.value = fresh;
};
const loadJobs = async () => { const loadJobs = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@@ -78,9 +117,10 @@ const loadJobs = async () => {
const response = await rpcClient.listAdminJobs({ const response = await rpcClient.listAdminJobs({
offset: 0, offset: 0,
limit: 50, limit: 50,
agentId: activeAgentFilter.value.trim() || undefined, agentId: appliedAgentFilter.value.trim() || undefined,
}); });
rows.value = response.jobs ?? []; rows.value = response.jobs ?? [];
syncSelectedRow();
} catch (err: any) { } catch (err: any) {
error.value = err?.message || "Failed to load admin jobs"; error.value = err?.message || "Failed to load admin jobs";
} finally { } finally {
@@ -88,14 +128,33 @@ const loadJobs = async () => {
} }
}; };
const applyFilters = async () => {
appliedAgentFilter.value = activeAgentFilter.value;
await loadJobs();
};
const loadSelectedLogs = async (jobId: string) => {
const response = await rpcClient.getAdminJobLogs({ id: jobId });
selectedLogs.value = response.logs || "No logs available.";
};
const selectRow = async (row: AdminJobRow) => {
selectedRow.value = row;
selectedLogs.value = "Loading logs...";
try {
await loadSelectedLogs(row.id);
} catch {
selectedLogs.value = "No logs available.";
}
};
const openLogsDialog = async (row: AdminJobRow) => { const openLogsDialog = async (row: AdminJobRow) => {
selectedRow.value = row; selectedRow.value = row;
actionError.value = null; actionError.value = null;
selectedLogs.value = "Loading logs..."; selectedLogs.value = "Loading logs...";
logsOpen.value = true; logsOpen.value = true;
try { try {
const response = await rpcClient.getAdminJobLogs({ id: row.id }); await loadSelectedLogs(row.id);
selectedLogs.value = response.logs || "No logs available.";
} catch (err: any) { } catch (err: any) {
selectedLogs.value = ""; selectedLogs.value = "";
actionError.value = err?.message || "Failed to load job logs"; actionError.value = err?.message || "Failed to load job logs";
@@ -145,7 +204,6 @@ const submitCancel = async () => {
try { try {
await rpcClient.cancelAdminJob({ id: selectedRow.value.id }); await rpcClient.cancelAdminJob({ id: selectedRow.value.id });
cancelOpen.value = false; cancelOpen.value = false;
selectedRow.value = null;
await loadJobs(); await loadJobs();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to cancel job"; actionError.value = err?.message || "Failed to cancel job";
@@ -161,7 +219,6 @@ const submitRetry = async () => {
try { try {
await rpcClient.retryAdminJob({ id: selectedRow.value.id }); await rpcClient.retryAdminJob({ id: selectedRow.value.id });
retryOpen.value = false; retryOpen.value = false;
selectedRow.value = null;
await loadJobs(); await loadJobs();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to retry job"; actionError.value = err?.message || "Failed to retry job";
@@ -170,6 +227,23 @@ const submitRetry = async () => {
} }
}; };
const formatDate = (value?: string) => {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const formatProgress = (value?: number) => `${Number(value ?? 0).toFixed(2)}%`;
const statusBadgeClass = (status?: string) => {
const normalized = String(status || "").toLowerCase();
if (["success", "completed", "done"].includes(normalized)) return "border-emerald-200 bg-emerald-50 text-emerald-700";
if (["running", "processing"].includes(normalized)) return "border-sky-200 bg-sky-50 text-sky-700";
if (["pending", "queued"].includes(normalized)) return "border-amber-200 bg-amber-50 text-amber-700";
if (["failure", "failed", "cancelled"].includes(normalized)) return "border-rose-200 bg-rose-50 text-rose-700";
return "border-slate-200 bg-slate-100 text-slate-700";
};
useAdminRuntimeMqtt(({ topic, payload }) => { useAdminRuntimeMqtt(({ topic, payload }) => {
if (topic.startsWith("picpic/job/") && payload?.type === "job_update") { if (topic.startsWith("picpic/job/") && payload?.type === "job_update") {
const update = payload.payload; const update = payload.payload;
@@ -188,12 +262,14 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
if (topic.startsWith("picpic/logs/") && payload?.job_id) { if (topic.startsWith("picpic/logs/") && payload?.job_id) {
const row = rows.value.find((item) => item.id === payload.job_id); const row = rows.value.find((item) => item.id === payload.job_id);
if (row && typeof payload.line === "string") { if (row && typeof payload.line === "string") {
row.logs = `${row.logs || ""}${payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`}`;
row.progress = payload.progress ?? row.progress; row.progress = payload.progress ?? row.progress;
row.updatedAt = new Date().toISOString();
} }
if (selectedRow.value?.id === payload.job_id && typeof payload.line === "string") { if (selectedRow.value?.id === payload.job_id && typeof payload.line === "string") {
const nextLine = payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`; const nextLine = payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`;
selectedLogs.value = `${selectedLogs.value === "Loading logs..." ? "" : selectedLogs.value}${nextLine}`; selectedLogs.value = `${selectedLogs.value === "Loading logs..." || selectedLogs.value === "No logs available." ? "" : selectedLogs.value}${nextLine}`;
selectedRow.value.progress = payload.progress ?? selectedRow.value.progress;
selectedRow.value.updatedAt = new Date().toISOString();
} }
} }
@@ -214,64 +290,123 @@ onMounted(loadJobs);
<template> <template>
<AdminSectionShell <AdminSectionShell
title="Admin Jobs" title="Admin Jobs"
description="Runtime job queue over admin gRPC service." description="Queue visibility, live progress and operator interventions backed by the existing admin runtime contract."
eyebrow="Runtime"
:badge="`${rows.length} jobs loaded`"
> >
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between"> <template #toolbar>
<div class="w-full max-w-sm space-y-2"> <AppButton size="sm" variant="secondary" :loading="loading" @click="loadJobs">Refresh</AppButton>
<label class="text-sm font-medium text-gray-700">Filter by agent ID</label>
<div class="flex gap-2">
<AppInput v-model="activeAgentFilter" placeholder="Optional agent ID" />
<AppButton size="sm" variant="secondary" @click="loadJobs">Apply</AppButton>
</div>
</div>
<AppButton size="sm" @click="actionError = null; createOpen = true">Create job</AppButton> <AppButton size="sm" @click="actionError = null; createOpen = true">Create job</AppButton>
</template>
<template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ item.label }}</div>
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
</div>
</template>
<template #aside>
<div class="space-y-5">
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected job</div>
<div v-if="selectedRow" class="space-y-4">
<div>
<div class="text-lg font-semibold text-white">{{ selectedRow.name || 'Untitled job' }}</div>
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.id }}</div>
</div>
<div class="grid gap-3">
<div v-for="item in selectedMeta" :key="item.label" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</div>
<div class="mt-1 text-sm font-medium text-white">{{ item.value }}</div>
</div>
</div>
<div class="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3">
<div class="flex items-center justify-between gap-2 text-[11px] uppercase tracking-[0.18em] text-slate-500">
<span>Live logs</span>
<button type="button" class="text-slate-300 transition hover:text-white" @click="openLogsDialog(selectedRow)">Expand</button>
</div>
<pre class="mt-3 max-h-72 overflow-auto whitespace-pre-wrap break-words text-xs leading-5 text-emerald-300">{{ selectedLogs || 'No logs available.' }}</pre>
</div>
<div class="grid gap-2">
<AppButton size="sm" variant="secondary" @click="openLogsDialog(selectedRow)">Open full logs</AppButton>
<AppButton v-if="isRetryable(selectedRow)" size="sm" @click="openRetryDialog(selectedRow)">Retry job</AppButton>
<AppButton v-if="isCancelable(selectedRow)" size="sm" variant="danger" @click="openCancelDialog(selectedRow)">Cancel job</AppButton>
</div>
</div>
<div v-else class="rounded-2xl border border-dashed border-white/15 px-4 py-5 text-sm leading-6 text-slate-400">
Select a job to inspect runtime state and tail logs from the existing MQTT stream.
</div>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 rounded-2xl border border-slate-200 bg-slate-50/80 p-4 xl:grid-cols-[220px_minmax(0,1fr)_auto] xl:items-end">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Agent filter</label>
<AppInput v-model="activeAgentFilter" placeholder="Optional agent id" @enter="applyFilters" />
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Search</label>
<AppInput v-model="search" placeholder="Search job id, name, user, agent" />
</div>
<div class="flex items-center gap-2 xl:justify-end">
<AppButton size="sm" variant="ghost" @click="activeAgentFilter = ''; appliedAgentFilter = ''; search = ''; loadJobs()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div>
</div> </div>
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
{{ error }}
</div>
<div v-else class="overflow-x-auto"> <div v-else class="overflow-hidden rounded-2xl border border-slate-200">
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm"> <table class="min-w-full text-left text-sm">
<thead> <thead class="bg-slate-50/90 text-slate-500">
<tr class="border-b border-gray-200 text-gray-500"> <tr>
<th class="py-3 pr-4 font-medium">Name</th> <th class="px-4 py-3 font-semibold">Job</th>
<th class="py-3 pr-4 font-medium">Status</th> <th class="px-4 py-3 font-semibold">Status</th>
<th class="py-3 pr-4 font-medium">Agent</th> <th class="px-4 py-3 font-semibold">Agent</th>
<th class="py-3 pr-4 font-medium">Priority</th> <th class="px-4 py-3 font-semibold">Priority</th>
<th class="py-3 pr-4 font-medium">Progress</th> <th class="px-4 py-3 font-semibold">Progress</th>
<th class="py-3 pr-4 font-medium">Updated</th> <th class="px-4 py-3 font-semibold">Updated</th>
<th class="py-3 pr-4 text-right font-medium">Actions</th> <th class="px-4 py-3 text-right font-semibold">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading" class="border-b border-gray-100"> <tr v-if="loading" class="border-t border-slate-200">
<td colspan="7" class="py-6 text-center text-gray-500">Loading jobs...</td> <td colspan="7" class="px-4 py-10 text-center text-slate-500">Loading jobs...</td>
</tr> </tr>
<tr v-else-if="rows.length === 0" class="border-b border-gray-100"> <tr v-else-if="filteredRows.length === 0" class="border-t border-slate-200">
<td colspan="7" class="py-6 text-center text-gray-500">No jobs found.</td> <td colspan="7" class="px-4 py-10 text-center text-slate-500">No jobs matched the current filters.</td>
</tr> </tr>
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top"> <tr v-for="row in filteredRows" :key="row.id" class="border-t border-slate-200 transition-colors hover:bg-slate-50/70" :class="selectedRow?.id === row.id ? 'bg-sky-50/60' : ''">
<td class="py-3 pr-4 text-gray-700"> <td class="px-4 py-3">
<div class="font-medium">{{ row.name || row.id }}</div> <button class="text-left" @click="selectRow(row)">
<div class="text-xs text-gray-500">{{ row.id }}</div> <div class="font-medium text-slate-900">{{ row.name || 'Untitled job' }}</div>
<div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
</button>
</td> </td>
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td> <td class="px-4 py-3">
<td class="py-3 pr-4 text-gray-700">{{ row.agentId || '—' }}</td> <span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="statusBadgeClass(row.status)">
<td class="py-3 pr-4 text-gray-700">{{ row.priority }}</td> {{ row.status || 'UNKNOWN' }}
<td class="py-3 pr-4 text-gray-700">{{ row.progress || 0 }}</td> </span>
<td class="py-3 pr-4 text-gray-700">{{ row.updatedAt ? new Date(row.updatedAt).toLocaleString() : '—' }}</td> </td>
<td class="py-3 text-right"> <td class="px-4 py-3 text-slate-700">{{ row.agentId || 'Unassigned' }}</td>
<td class="px-4 py-3 text-slate-700">{{ row.priority ?? 0 }}</td>
<td class="px-4 py-3 text-slate-700">{{ formatProgress(row.progress) }}</td>
<td class="px-4 py-3 text-slate-500">{{ formatDate(row.updatedAt) }}</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<AppButton size="sm" variant="secondary" @click="openLogsDialog(row)">Logs</AppButton> <AppButton size="sm" variant="secondary" @click="openLogsDialog(row)">Logs</AppButton>
<AppButton size="sm" variant="secondary" @click="openRetryDialog(row)">Retry</AppButton> <AppButton v-if="isRetryable(row)" size="sm" @click="openRetryDialog(row)">Retry</AppButton>
<AppButton size="sm" variant="danger" @click="openCancelDialog(row)">Cancel</AppButton> <AppButton v-if="isCancelable(row)" size="sm" variant="danger" @click="openCancelDialog(row)">Cancel</AppButton>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div>
</AdminSectionShell> </AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create job" maxWidthClass="max-w-2xl" @close="actionError = null"> <AppDialog v-model:visible="createOpen" title="Create job" maxWidthClass="max-w-2xl" @close="actionError = null">
@@ -280,7 +415,7 @@ onMounted(loadJobs);
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Command</label> <label class="text-sm font-medium text-gray-700">Command</label>
<AppInput v-model="createForm.command" placeholder="ffmpeg -i ..." /> <textarea v-model="createForm.command" rows="4" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="ffmpeg -i ..." />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Image</label> <label class="text-sm font-medium text-gray-700">Image</label>
@@ -316,10 +451,10 @@ onMounted(loadJobs);
</template> </template>
</AppDialog> </AppDialog>
<AppDialog v-model:visible="logsOpen" title="Job logs" maxWidthClass="max-w-3xl" @close="actionError = null"> <AppDialog v-model:visible="logsOpen" title="Job logs" maxWidthClass="max-w-4xl" @close="actionError = null">
<div class="space-y-4"> <div class="space-y-4">
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div> <div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
<div class="rounded-lg border border-gray-200 bg-gray-950 p-4 font-mono text-xs text-green-300 whitespace-pre-wrap max-h-120 overflow-auto"> <div class="rounded-lg border border-slate-200 bg-slate-950 p-4 font-mono text-xs text-emerald-300 whitespace-pre-wrap max-h-140 overflow-auto">
{{ selectedLogs || 'No logs available.' }} {{ selectedLogs || 'No logs available.' }}
</div> </div>
</div> </div>

View File

@@ -1,3 +1,86 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const sections = [
{ to: "/admin/overview", label: "Overview", description: "KPIs, usage and runtime pulse" },
{ to: "/admin/users", label: "Users", description: "Accounts, plans and moderation" },
{ to: "/admin/videos", label: "Videos", description: "Cross-user media inventory" },
{ to: "/admin/payments", label: "Payments", description: "Revenue, invoices and state changes" },
{ to: "/admin/plans", label: "Plans", description: "Catalog and subscription offers" },
{ to: "/admin/ad-templates", label: "Ad Templates", description: "VAST templates and defaults" },
{ to: "/admin/jobs", label: "Jobs", description: "Queue, retries and live logs" },
{ to: "/admin/agents", label: "Agents", description: "Workers, health and maintenance" },
{ to: "/admin/logs", label: "Logs", description: "Direct runtime log lookup" },
] as const;
const activeSection = computed(() => {
return sections.find((section) => route.path === section.to || route.path.startsWith(`${section.to}/`)) ?? sections[0];
});
</script>
<template> <template>
<section class="space-y-5">
<div class="overflow-hidden rounded-[28px] border border-slate-200 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_38%),linear-gradient(135deg,#020617,#0f172a_52%,#111827)] px-6 py-6 text-white shadow-[0_24px_80px_-40px_rgba(15,23,42,0.8)]">
<div class="flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
<div class="max-w-3xl space-y-3">
<div class="inline-flex items-center rounded-full border border-white/15 bg-white/8 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100">
Admin Console
</div>
<div>
<h1 class="text-3xl font-semibold tracking-tight text-white md:text-4xl">Operate the entire Stream workspace from one surface.</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-300 md:text-base">
Screen coverage is aligned around the current Tiny-RPC + gRPC admin contract. Use the navigation below to jump between CRUD workflows, runtime operations and diagnostics.
</p>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-3 xl:min-w-[420px]">
<div class="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur-sm">
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-400">Current module</div>
<div class="mt-2 text-lg font-semibold text-white">{{ activeSection.label }}</div>
<div class="mt-1 text-sm text-slate-300">{{ activeSection.description }}</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur-sm">
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-400">Coverage</div>
<div class="mt-2 text-lg font-semibold text-white">{{ sections.length }} screens</div>
<div class="mt-1 text-sm text-slate-300">Overview, CRUD, runtime and logs.</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur-sm">
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-400">Data path</div>
<div class="mt-2 text-lg font-semibold text-white">Tiny-RPC</div>
<div class="mt-1 text-sm text-slate-300">Canonical gRPC-backed admin transport.</div>
</div>
</div>
</div>
</div>
<div class="grid gap-5 xl:grid-cols-[260px_minmax(0,1fr)]">
<aside class="rounded-[28px] border border-slate-200 bg-white p-3 shadow-[0_20px_60px_-40px_rgba(15,23,42,0.35)]">
<nav class="space-y-1">
<router-link
v-for="section in sections"
:key="section.to"
:to="section.to"
class="group flex items-start gap-3 rounded-2xl px-4 py-3 transition-all duration-200"
:class="route.path === section.to || route.path.startsWith(`${section.to}/`) ? 'bg-slate-950 text-white shadow-[0_18px_36px_-24px_rgba(15,23,42,0.8)]' : 'text-slate-700 hover:bg-slate-50'"
>
<div class="mt-0.5 h-2.5 w-2.5 rounded-full" :class="route.path === section.to || route.path.startsWith(`${section.to}/`) ? 'bg-sky-400' : 'bg-slate-300 group-hover:bg-slate-500'" />
<div class="min-w-0">
<div class="text-sm font-semibold tracking-tight">{{ section.label }}</div>
<div class="mt-1 text-xs leading-5" :class="route.path === section.to || route.path.startsWith(`${section.to}/`) ? 'text-slate-300' : 'text-slate-500'">
{{ section.description }}
</div>
</div>
</router-link>
</nav>
</aside>
<div class="min-w-0">
<router-view /> <router-view />
</div>
</div>
</section>
</template> </template>

View File

@@ -3,34 +3,56 @@ import { client as rpcClient } from "@/api/rpcclient";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt"; import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/app/AppButton.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/app/AppInput.vue";
import { ref } from "vue"; import { computed, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const jobId = ref(""); const jobId = ref("");
const activeJobId = ref("");
const logs = ref("Enter a job ID and load logs."); const logs = ref("Enter a job ID and load logs.");
const liveLineCount = ref(0);
const countLogLines = (value: string) => value.split("\n").filter(Boolean).length;
const summary = computed(() => [
{ label: "Tracking job", value: activeJobId.value || "—" },
{ label: "Live lines", value: liveLineCount.value },
]);
const loadLogs = async () => { const loadLogs = async () => {
if (!jobId.value.trim()) return; if (!jobId.value.trim()) return;
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await rpcClient.getAdminJobLogs({ id: jobId.value.trim() }); activeJobId.value = jobId.value.trim();
const response = await rpcClient.getAdminJobLogs({ id: activeJobId.value });
logs.value = response.logs || "No logs available."; logs.value = response.logs || "No logs available.";
liveLineCount.value = logs.value === "No logs available." ? 0 : countLogLines(logs.value);
} catch (err: any) { } catch (err: any) {
error.value = err?.message || "Failed to load logs"; error.value = err?.message || "Failed to load logs";
logs.value = ""; logs.value = "";
activeJobId.value = "";
liveLineCount.value = 0;
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
const clearLogs = () => {
jobId.value = "";
activeJobId.value = "";
error.value = null;
logs.value = "Enter a job ID and load logs.";
liveLineCount.value = 0;
};
useAdminRuntimeMqtt(({ topic, payload }) => { useAdminRuntimeMqtt(({ topic, payload }) => {
if (!jobId.value.trim()) return; if (!activeJobId.value) return;
if (topic === `picpic/logs/${jobId.value.trim()}` && payload?.job_id === jobId.value.trim() && typeof payload.line === "string") { if (topic === `picpic/logs/${activeJobId.value}` && payload?.job_id === activeJobId.value && typeof payload.line === "string") {
const nextLine = payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`; const nextLine = payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`;
logs.value = `${logs.value === "Enter a job ID and load logs." ? "" : logs.value}${nextLine}`; logs.value = `${logs.value === "Enter a job ID and load logs." || logs.value === "No logs available." ? "" : logs.value}${nextLine}`;
liveLineCount.value += countLogLines(nextLine);
} }
}); });
</script> </script>
@@ -38,22 +60,58 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
<template> <template>
<AdminSectionShell <AdminSectionShell
title="Admin Logs" title="Admin Logs"
description="Fetch persisted logs by job ID over admin gRPC service." description="Fetch persisted output and continue tailing the selected job over the existing MQTT log stream."
eyebrow="Observability"
:badge="activeJobId ? 'Live tail attached' : 'Awaiting job selection'"
> >
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-end"> <template #toolbar>
<div class="w-full max-w-xl space-y-2"> <AppButton size="sm" variant="secondary" :loading="loading" @click="loadLogs">Load logs</AppButton>
<label class="text-sm font-medium text-gray-700">Job ID</label> </template>
<AppInput v-model="jobId" placeholder="job-..." />
<template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ item.label }}</div>
<div class="mt-2 truncate text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
</div>
</template>
<template #aside>
<div class="space-y-5">
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Tail status</div>
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">Current channel</div>
<div class="mt-1 break-all text-sm font-medium text-white">{{ activeJobId ? `picpic/logs/${activeJobId}` : 'No active stream' }}</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm leading-6 text-slate-300">
Persisted logs are loaded once from gRPC, then appended live from MQTT frames for the same job.
</div>
<AppButton size="sm" variant="secondary" @click="clearLogs">Clear session</AppButton>
</div>
</template>
<div class="space-y-4">
<div class="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-slate-50/80 p-4 lg:flex-row lg:items-end">
<div class="w-full max-w-xl space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Job ID</label>
<AppInput v-model="jobId" placeholder="job-..." @enter="loadLogs" />
</div>
<div class="flex items-center gap-2">
<AppButton size="sm" variant="ghost" @click="clearLogs">Reset</AppButton>
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadLogs">Fetch</AppButton>
</div> </div>
<AppButton size="sm" :loading="loading" @click="loadLogs">Load logs</AppButton>
</div> </div>
<div v-if="error" class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ error }} {{ error }}
</div> </div>
<div class="rounded-xl border border-gray-200 bg-gray-950 p-4 font-mono text-sm text-green-300 whitespace-pre-wrap min-h-80 overflow-auto"> <div class="rounded-[24px] border border-slate-200 bg-slate-950 p-4 shadow-[0_12px_40px_-32px_rgba(15,23,42,0.6)]">
{{ loading ? 'Loading logs...' : logs }} <div class="mb-3 flex items-center justify-between gap-3 text-[11px] uppercase tracking-[0.18em] text-slate-500">
<span>Runtime output</span>
<span>{{ activeJobId || 'idle' }}</span>
</div>
<pre class="min-h-96 overflow-auto whitespace-pre-wrap break-words font-mono text-sm leading-6 text-emerald-300">{{ loading ? 'Loading logs...' : logs }}</pre>
</div>
</div> </div>
</AdminSectionShell> </AdminSectionShell>
</template> </template>

View File

@@ -1,28 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import StatsCard from "@/components/dashboard/StatsCard.vue"; import AppButton from "@/components/app/AppButton.vue";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminDashboard = Awaited<ReturnType<typeof rpcClient.getAdminDashboard>>;
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const dashboard = ref<any | null>(null); const dashboard = ref<AdminDashboard | null>(null);
const cards = computed(() => { const cards = computed(() => {
const data = dashboard.value; const data = dashboard.value;
return [ return [
{ title: "Total users", value: data?.totalUsers ?? 0, color: "primary" as const }, { title: "Total users", value: data?.totalUsers ?? 0, note: `${data?.newUsersToday ?? 0} new today` },
{ title: "Total videos", value: data?.totalVideos ?? 0, color: "info" as const }, { title: "Total videos", value: data?.totalVideos ?? 0, note: `${data?.newVideosToday ?? 0} new today` },
{ title: "Payments", value: data?.totalPayments ?? 0, color: "success" as const }, { title: "Payments", value: data?.totalPayments ?? 0, note: "Completed finance events" },
{ title: "Revenue", value: data?.totalRevenue ?? 0, color: "warning" as const }, { title: "Revenue", value: data?.totalRevenue ?? 0, note: "Lifetime gross amount" },
{ title: "Active subscriptions", value: data?.activeSubscriptions ?? 0, color: "primary" as const },
{ title: "Ad templates", value: data?.totalAdTemplates ?? 0, color: "info" as const },
{ title: "New users today", value: data?.newUsersToday ?? 0, color: "success" as const },
{ title: "New videos today", value: data?.newVideosToday ?? 0, color: "warning" as const },
]; ];
}); });
onMounted(async () => { const secondaryCards = computed(() => {
const data = dashboard.value;
return [
{ title: "Active subscriptions", value: data?.activeSubscriptions ?? 0 },
{ title: "Ad templates", value: data?.totalAdTemplates ?? 0 },
{ title: "New users today", value: data?.newUsersToday ?? 0 },
{ title: "New videos today", value: data?.newVideosToday ?? 0 },
];
});
const highlights = computed(() => {
const data = dashboard.value;
return [
{ label: "Acquisition", value: `${data?.newUsersToday ?? 0} user signups in the current day window.` },
{ label: "Content velocity", value: `${data?.newVideosToday ?? 0} newly created videos landed today.` },
{ label: "Catalog depth", value: `${data?.totalAdTemplates ?? 0} ad templates available to pair with uploads.` },
];
});
const loadDashboard = async () => {
loading.value = true;
error.value = null;
try { try {
dashboard.value = await rpcClient.getAdminDashboard(); dashboard.value = await rpcClient.getAdminDashboard();
} catch (err: any) { } catch (err: any) {
@@ -30,26 +49,70 @@ onMounted(async () => {
} finally { } finally {
loading.value = false; loading.value = false;
} }
}); };
onMounted(loadDashboard);
</script> </script>
<template> <template>
<AdminSectionShell <AdminSectionShell
title="Admin Overview" title="Admin Overview"
description="System-wide metrics from backend gRPC admin service." description="High-signal workspace metrics surfaced from the admin gRPC dashboard contract."
eyebrow="Control room"
badge="Realtime-ready summary"
> >
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <template #toolbar>
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadDashboard">
Refresh metrics
</AppButton>
</template>
<template #aside>
<div class="space-y-5">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Operations notes</div>
<div class="mt-3 space-y-3">
<div v-for="item in highlights" :key="item.label" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</div>
<div class="mt-1 text-sm leading-6 text-slate-200">{{ item.value }}</div>
</div>
</div>
</div>
</div>
</template>
<div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ error }} {{ error }}
</div> </div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div v-else class="space-y-5">
<StatsCard <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
v-for="card in cards" <div v-for="card in cards" :key="card.title" class="rounded-[24px] border border-slate-200 bg-[linear-gradient(180deg,#ffffff,#f8fafc)] p-5 shadow-[0_12px_40px_-34px_rgba(15,23,42,0.45)]">
:key="card.title" <div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ card.title }}</div>
:title="card.title" <div class="mt-3 text-3xl font-semibold tracking-tight text-slate-950">{{ loading ? '—' : card.value }}</div>
:value="loading ? 0 : card.value" <div class="mt-2 text-sm text-slate-500">{{ card.note }}</div>
:color="card.color" </div>
/> </div>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div class="rounded-[24px] border border-slate-200 bg-slate-50/70 p-5">
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">System snapshot</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div v-for="card in secondaryCards" :key="card.title" class="rounded-2xl border border-slate-200 bg-white px-4 py-4">
<div class="text-sm text-slate-500">{{ card.title }}</div>
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ loading ? '—' : card.value }}</div>
</div>
</div>
</div>
<div class="rounded-[24px] border border-slate-200 bg-white p-5">
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Dashboard source</div>
<div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
<p>This overview intentionally stays on top of the existing admin dashboard RPC instead of composing a new transport layer.</p>
<p>Use module pages for operational actions, while this screen remains a concise summary surface for operators landing in the console.</p>
</div>
</div>
</div>
</div> </div>
</AdminSectionShell> </AdminSectionShell>
</template> </template>

View File

@@ -3,23 +3,31 @@ import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/app/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/app/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/app/AppInput.vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminPaymentRow = any; type ListPaymentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminPayments>>;
type AdminPaymentRow = NonNullable<ListPaymentsResponse["payments"]>[number];
const paymentMethodOptions = ["TOPUP", "WALLET"] as const;
const statusOptions = ["PENDING", "SUCCESS", "FAILED", "CANCELLED"] as const;
const statusFilterOptions = ["", ...statusOptions] as const;
const loading = ref(true); const loading = ref(true);
const submitting = ref(false); const submitting = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const actionError = ref<string | null>(null); const actionError = ref<string | null>(null);
const rows = ref<AdminPaymentRow[]>([]); const rows = ref<AdminPaymentRow[]>([]);
const total = ref(0);
const limit = ref(12);
const page = ref(1);
const selectedRow = ref<AdminPaymentRow | null>(null); const selectedRow = ref<AdminPaymentRow | null>(null);
const userFilter = ref("");
const appliedUserFilter = ref("");
const statusFilter = ref<(typeof statusFilterOptions)[number]>("");
const createOpen = ref(false); const createOpen = ref(false);
const statusOpen = ref(false); const statusOpen = ref(false);
const paymentMethodOptions = ["TOPUP", "WALLET"];
const statusOptions = ["PENDING", "SUCCESS", "FAILED", "CANCELLED"];
const createForm = reactive({ const createForm = reactive({
userId: "", userId: "",
planId: "", planId: "",
@@ -35,13 +43,43 @@ const statusForm = reactive({
const canCreate = computed(() => createForm.userId.trim() && createForm.planId.trim() && createForm.termMonths >= 1 && createForm.paymentMethod.trim()); const canCreate = computed(() => createForm.userId.trim() && createForm.planId.trim() && createForm.termMonths >= 1 && createForm.paymentMethod.trim());
const canUpdateStatus = computed(() => statusForm.id.trim() && statusForm.status.trim()); const canUpdateStatus = computed(() => statusForm.id.trim() && statusForm.status.trim());
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
const summary = computed(() => [
{ label: "Visible payments", value: rows.value.length },
{ label: "Successful", value: rows.value.filter((row) => row.status === "SUCCESS").length },
{ label: "Pending", value: rows.value.filter((row) => row.status === "PENDING").length },
{ label: "Total records", value: total.value },
]);
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
return [
{ label: "User", value: selectedRow.value.userEmail || selectedRow.value.userId || "—" },
{ label: "Plan", value: selectedRow.value.planName || selectedRow.value.planId || "—" },
{ label: "Method", value: selectedRow.value.paymentMethod || "—" },
{ label: "Amount", value: formatMoney(selectedRow.value.amount, selectedRow.value.currency) },
{ label: "Created", value: formatDate(selectedRow.value.createdAt) },
{ label: "Invoice", value: selectedRow.value.invoiceId || "—" },
];
});
const loadPayments = async () => { const loadPayments = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await rpcClient.listAdminPayments({ page: 1, limit: 20 }); const response = await rpcClient.listAdminPayments({
page: page.value,
limit: limit.value,
userId: appliedUserFilter.value.trim() || undefined,
status: statusFilter.value || undefined,
});
rows.value = response.payments ?? []; rows.value = response.payments ?? [];
total.value = response.total ?? rows.value.length;
limit.value = response.limit ?? limit.value;
page.value = response.page ?? page.value;
if (selectedRow.value?.id) {
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
if (fresh) selectedRow.value = fresh;
}
} catch (err: any) { } catch (err: any) {
error.value = err?.message || "Failed to load admin payments"; error.value = err?.message || "Failed to load admin payments";
} finally { } finally {
@@ -60,10 +98,15 @@ const resetCreateForm = () => {
const closeDialogs = () => { const closeDialogs = () => {
createOpen.value = false; createOpen.value = false;
statusOpen.value = false; statusOpen.value = false;
selectedRow.value = null;
actionError.value = null; actionError.value = null;
}; };
const applyFilters = async () => {
page.value = 1;
appliedUserFilter.value = userFilter.value;
await loadPayments();
};
const openStatusDialog = (row: AdminPaymentRow) => { const openStatusDialog = (row: AdminPaymentRow) => {
selectedRow.value = row; selectedRow.value = row;
actionError.value = null; actionError.value = null;
@@ -104,7 +147,6 @@ const submitStatusUpdate = async () => {
status: statusForm.status, status: statusForm.status,
}); });
statusOpen.value = false; statusOpen.value = false;
selectedRow.value = null;
await loadPayments(); await loadPayments();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to update payment"; actionError.value = err?.message || "Failed to update payment";
@@ -113,50 +155,149 @@ const submitStatusUpdate = async () => {
} }
}; };
const previousPage = async () => {
if (page.value <= 1) return;
page.value -= 1;
await loadPayments();
};
const nextPage = async () => {
if (page.value >= totalPages.value) return;
page.value += 1;
await loadPayments();
};
const formatDate = (value?: string) => {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const formatMoney = (amount?: number, currency?: string) => `${amount ?? 0} ${currency || "USD"}`;
const statusBadgeClass = (status?: string) => {
switch (status) {
case "SUCCESS":
return "border-emerald-200 bg-emerald-50 text-emerald-700";
case "PENDING":
return "border-amber-200 bg-amber-50 text-amber-700";
case "FAILED":
case "CANCELLED":
return "border-rose-200 bg-rose-50 text-rose-700";
default:
return "border-slate-200 bg-slate-100 text-slate-700";
}
};
watch(statusFilter, async () => {
page.value = 1;
await loadPayments();
});
onMounted(loadPayments); onMounted(loadPayments);
</script> </script>
<template> <template>
<AdminSectionShell <AdminSectionShell
title="Admin Payments" title="Admin Payments"
description="Payment history from admin gRPC service." description="Track invoices, manual plan activations and state changes with a finance-focused operator view."
eyebrow="Finance"
:badge="`${total} total payments`"
> >
<div class="mb-4 flex justify-end"> <template #toolbar>
<AppButton size="sm" variant="secondary" @click="loadPayments">Refresh</AppButton>
<AppButton size="sm" @click="actionError = null; createOpen = true">Create payment</AppButton> <AppButton size="sm" @click="actionError = null; createOpen = true">Create payment</AppButton>
</template>
<template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ item.label }}</div>
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
</div>
</template>
<template #aside>
<div class="space-y-5">
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected payment</div>
<div v-if="selectedRow" class="space-y-4">
<div>
<div class="text-lg font-semibold text-white">{{ formatMoney(selectedRow.amount, selectedRow.currency) }}</div>
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.id }}</div>
</div>
<div class="grid gap-3">
<div v-for="item in selectedMeta" :key="item.label" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</div>
<div class="mt-1 text-sm font-medium text-white">{{ item.value }}</div>
</div>
</div>
<AppButton size="sm" @click="openStatusDialog(selectedRow)">Update status</AppButton>
</div>
<div v-else class="rounded-2xl border border-dashed border-white/15 px-4 py-5 text-sm leading-6 text-slate-400">
Select a payment to review invoice metadata and push a status change.
</div>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 rounded-2xl border border-slate-200 bg-slate-50/80 p-4 xl:grid-cols-[220px_220px_auto] xl:items-end">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">User filter</label>
<AppInput v-model="userFilter" placeholder="Optional user id" @enter="applyFilters" />
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Status</label>
<select v-model="statusFilter" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
<option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option>
</select>
</div>
<div class="flex items-center gap-2 xl:justify-end">
<AppButton size="sm" variant="ghost" @click="userFilter = ''; appliedUserFilter = ''; statusFilter = ''; loadPayments()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div>
</div> </div>
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ error }} {{ error }}
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else class="overflow-hidden rounded-2xl border border-slate-200">
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm"> <table class="min-w-full text-left text-sm">
<thead> <thead class="bg-slate-50/90 text-slate-500">
<tr class="border-b border-gray-200 text-gray-500"> <tr>
<th class="py-3 pr-4 font-medium">ID</th> <th class="px-4 py-3 font-semibold">Payment</th>
<th class="py-3 pr-4 font-medium">User</th> <th class="px-4 py-3 font-semibold">User</th>
<th class="py-3 pr-4 font-medium">Amount</th> <th class="px-4 py-3 font-semibold">Plan</th>
<th class="py-3 pr-4 font-medium">Status</th> <th class="px-4 py-3 font-semibold">Method</th>
<th class="py-3 pr-4 font-medium">Plan</th> <th class="px-4 py-3 font-semibold">Status</th>
<th class="py-3 pr-4 font-medium">Method</th> <th class="px-4 py-3 font-semibold">Created</th>
<th class="py-3 pr-4 text-right font-medium">Actions</th> <th class="px-4 py-3 text-right font-semibold">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading" class="border-b border-gray-100"> <tr v-if="loading" class="border-t border-slate-200">
<td colspan="7" class="py-6 text-center text-gray-500">Loading payments...</td> <td colspan="7" class="px-4 py-10 text-center text-slate-500">Loading payments...</td>
</tr> </tr>
<tr v-else-if="rows.length === 0" class="border-b border-gray-100"> <tr v-else-if="rows.length === 0" class="border-t border-slate-200">
<td colspan="7" class="py-6 text-center text-gray-500">No payments found.</td> <td colspan="7" class="px-4 py-10 text-center text-slate-500">No payments matched the current filters.</td>
</tr> </tr>
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top"> <tr v-for="row in rows" :key="row.id" class="border-t border-slate-200 transition-colors hover:bg-slate-50/70" :class="selectedRow?.id === row.id ? 'bg-sky-50/60' : ''">
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td> <td class="px-4 py-3">
<td class="py-3 pr-4 text-gray-700">{{ row.userEmail || row.userId }}</td> <button class="text-left" @click="selectedRow = row">
<td class="py-3 pr-4 text-gray-700">{{ row.amount }} {{ row.currency }}</td> <div class="font-medium text-slate-900">{{ formatMoney(row.amount, row.currency) }}</div>
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td> <div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
<td class="py-3 pr-4 text-gray-700">{{ row.planName || row.planId || '—' }}</td> </button>
<td class="py-3 pr-4 text-gray-700">{{ row.paymentMethod || '—' }}</td> </td>
<td class="py-3 text-right"> <td class="px-4 py-3 text-slate-700">{{ row.userEmail || row.userId }}</td>
<td class="px-4 py-3 text-slate-700">{{ row.planName || row.planId || '—' }}</td>
<td class="px-4 py-3 text-slate-700">{{ row.paymentMethod || '—' }}</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="statusBadgeClass(row.status)">
{{ row.status }}
</span>
</td>
<td class="px-4 py-3 text-slate-500">{{ formatDate(row.createdAt) }}</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<AppButton size="sm" variant="secondary" @click="openStatusDialog(row)">Update status</AppButton> <AppButton size="sm" variant="secondary" @click="openStatusDialog(row)">Update status</AppButton>
</div> </div>
@@ -165,6 +306,16 @@ onMounted(loadPayments);
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex flex-col gap-3 border-t border-slate-200 bg-slate-50/70 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div class="text-xs font-medium uppercase tracking-[0.16em] text-slate-500">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
<div class="flex items-center gap-2">
<AppButton size="sm" variant="secondary" :disabled="page <= 1 || loading" @click="previousPage">Previous</AppButton>
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
</div>
</div>
</div>
</div>
</AdminSectionShell> </AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create admin payment" maxWidthClass="max-w-lg" @close="actionError = null"> <AppDialog v-model:visible="createOpen" title="Create admin payment" maxWidthClass="max-w-lg" @close="actionError = null">

View File

@@ -6,7 +6,10 @@ import AppInput from "@/components/app/AppInput.vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminPlanRow = any; type ListPlansResponse = Awaited<ReturnType<typeof rpcClient.listAdminPlans>>;
type AdminPlanRow = NonNullable<ListPlansResponse["plans"]>[number];
const cycleOptions = ["monthly", "quarterly", "yearly"] as const;
const loading = ref(true); const loading = ref(true);
const submitting = ref(false); const submitting = ref(false);
@@ -17,7 +20,6 @@ const selectedRow = ref<AdminPlanRow | null>(null);
const createOpen = ref(false); const createOpen = ref(false);
const editOpen = ref(false); const editOpen = ref(false);
const deleteOpen = ref(false); const deleteOpen = ref(false);
const cycleOptions = ["monthly", "quarterly", "yearly"];
const createForm = reactive({ const createForm = reactive({
name: "", name: "",
@@ -50,6 +52,22 @@ const parseFeatures = (value: string) =>
const canCreate = computed(() => createForm.name.trim() && createForm.cycle.trim() && createForm.storageLimit > 0 && createForm.uploadLimit > 0); const canCreate = computed(() => createForm.name.trim() && createForm.cycle.trim() && createForm.storageLimit > 0 && createForm.uploadLimit > 0);
const canUpdate = computed(() => editForm.id.trim() && editForm.name.trim() && editForm.cycle.trim() && editForm.storageLimit > 0 && editForm.uploadLimit > 0); const canUpdate = computed(() => editForm.id.trim() && editForm.name.trim() && editForm.cycle.trim() && editForm.storageLimit > 0 && editForm.uploadLimit > 0);
const summary = computed(() => [
{ label: "Plans", value: rows.value.length },
{ label: "Active", value: rows.value.filter((row) => row.isActive).length },
{ label: "Highest price", value: rows.value.reduce((max, row) => Math.max(max, Number(row.price ?? 0)), 0) },
{ label: "Avg storage", value: Math.round(rows.value.reduce((sum, row) => sum + Number(row.storageLimit ?? 0), 0) / Math.max(rows.value.length, 1)) },
]);
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
return [
{ label: "Cycle", value: selectedRow.value.cycle || "—" },
{ label: "Price", value: String(selectedRow.value.price ?? 0) },
{ label: "Storage", value: String(selectedRow.value.storageLimit ?? 0) },
{ label: "Uploads", value: String(selectedRow.value.uploadLimit ?? 0) },
{ label: "Status", value: selectedRow.value.isActive ? "ACTIVE" : "INACTIVE" },
];
});
const loadPlans = async () => { const loadPlans = async () => {
loading.value = true; loading.value = true;
@@ -57,6 +75,10 @@ const loadPlans = async () => {
try { try {
const response = await rpcClient.listAdminPlans(); const response = await rpcClient.listAdminPlans();
rows.value = response.plans ?? []; rows.value = response.plans ?? [];
if (selectedRow.value?.id) {
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
if (fresh) selectedRow.value = fresh;
}
} catch (err: any) { } catch (err: any) {
error.value = err?.message || "Failed to load admin plans"; error.value = err?.message || "Failed to load admin plans";
} finally { } finally {
@@ -79,7 +101,6 @@ const closeDialogs = () => {
createOpen.value = false; createOpen.value = false;
editOpen.value = false; editOpen.value = false;
deleteOpen.value = false; deleteOpen.value = false;
selectedRow.value = null;
actionError.value = null; actionError.value = null;
}; };
@@ -146,7 +167,6 @@ const submitEdit = async () => {
isActive: editForm.isActive, isActive: editForm.isActive,
}); });
editOpen.value = false; editOpen.value = false;
selectedRow.value = null;
await loadPlans(); await loadPlans();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to update plan"; actionError.value = err?.message || "Failed to update plan";
@@ -177,55 +197,102 @@ onMounted(loadPlans);
<template> <template>
<AdminSectionShell <AdminSectionShell
title="Admin Plans" title="Admin Plans"
description="Subscription plans managed via admin gRPC service." description="Maintain the subscription catalog with a card-based overview and direct editing workflows."
eyebrow="Catalog"
:badge="`${rows.length} plans loaded`"
> >
<div class="mb-4 flex justify-end"> <template #toolbar>
<AppButton size="sm" variant="secondary" @click="loadPlans">Refresh</AppButton>
<AppButton size="sm" @click="actionError = null; createOpen = true">Create plan</AppButton> <AppButton size="sm" @click="actionError = null; createOpen = true">Create plan</AppButton>
</template>
<template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ item.label }}</div>
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
</div>
</template>
<template #aside>
<div class="space-y-5">
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected plan</div>
<div v-if="selectedRow" class="space-y-4">
<div>
<div class="text-lg font-semibold text-white">{{ selectedRow.name }}</div>
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.description || 'No description' }}</div>
</div>
<div class="grid gap-3">
<div v-for="item in selectedMeta" :key="item.label" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</div>
<div class="mt-1 text-sm font-medium text-white">{{ item.value }}</div>
</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">Features</div>
<ul class="mt-2 space-y-1 text-sm text-slate-200">
<li v-for="feature in selectedRow.features || []" :key="feature"> {{ feature }}</li>
<li v-if="!(selectedRow.features || []).length" class="text-slate-400">No features listed.</li>
</ul>
</div>
<div class="grid gap-2">
<AppButton size="sm" @click="openEditDialog(selectedRow)">Edit plan</AppButton>
<AppButton size="sm" variant="danger" @click="openDeleteDialog(selectedRow)">Delete plan</AppButton>
</div>
</div>
<div v-else class="rounded-2xl border border-dashed border-white/15 px-4 py-5 text-sm leading-6 text-slate-400">
Choose a plan to inspect pricing, storage limits and feature bullets.
</div>
</div>
</template>
<div class="space-y-4">
<div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
<div v-else-if="loading" class="rounded-2xl border border-slate-200 px-4 py-10 text-center text-slate-500">Loading plans...</div>
<div v-else-if="rows.length === 0" class="rounded-2xl border border-slate-200 px-4 py-10 text-center text-slate-500">No plans found.</div>
<div v-else class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
<button
v-for="row in rows"
:key="row.id"
type="button"
class="rounded-[24px] border p-5 text-left transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_45px_-36px_rgba(15,23,42,0.45)]"
:class="selectedRow?.id === row.id ? 'border-sky-300 bg-sky-50/70' : 'border-slate-200 bg-white'"
@click="selectedRow = row"
>
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-lg font-semibold tracking-tight text-slate-950">{{ row.name }}</div>
<div class="mt-1 text-sm text-slate-500">{{ row.description || 'No description' }}</div>
</div>
<span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="row.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-100 text-slate-700'">
{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}
</span>
</div> </div>
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div class="mt-5 grid grid-cols-2 gap-3 text-sm text-slate-700">
{{ error }} <div class="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.16em] text-slate-500">Price</div>
<div class="mt-1 font-semibold text-slate-950">{{ row.price }}</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.16em] text-slate-500">Cycle</div>
<div class="mt-1 font-semibold text-slate-950">{{ row.cycle }}</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.16em] text-slate-500">Storage</div>
<div class="mt-1 font-semibold text-slate-950">{{ row.storageLimit }}</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.16em] text-slate-500">Uploads</div>
<div class="mt-1 font-semibold text-slate-950">{{ row.uploadLimit }}</div>
</div>
</div> </div>
<div v-else class="overflow-x-auto"> <div class="mt-5 flex items-center justify-end gap-2">
<table class="min-w-full text-left text-sm"> <AppButton size="sm" variant="secondary" @click.stop="openEditDialog(row)">Edit</AppButton>
<thead> <AppButton size="sm" variant="danger" @click.stop="openDeleteDialog(row)">Delete</AppButton>
<tr class="border-b border-gray-200 text-gray-500"> </div>
<th class="py-3 pr-4 font-medium">Name</th> </button>
<th class="py-3 pr-4 font-medium">Price</th>
<th class="py-3 pr-4 font-medium">Cycle</th>
<th class="py-3 pr-4 font-medium">Storage</th>
<th class="py-3 pr-4 font-medium">Uploads</th>
<th class="py-3 pr-4 font-medium">Status</th>
<th class="py-3 pr-4 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="loading" class="border-b border-gray-100">
<td colspan="7" class="py-6 text-center text-gray-500">Loading plans...</td>
</tr>
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
<td colspan="7" class="py-6 text-center text-gray-500">No plans found.</td>
</tr>
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
<td class="py-3 pr-4 text-gray-700">
<div class="font-medium">{{ row.name }}</div>
<div class="text-xs text-gray-500">{{ row.description || '—' }}</div>
</td>
<td class="py-3 pr-4 text-gray-700">{{ row.price }}</td>
<td class="py-3 pr-4 text-gray-700">{{ row.cycle }}</td>
<td class="py-3 pr-4 text-gray-700">{{ row.storageLimit }}</td>
<td class="py-3 pr-4 text-gray-700">{{ row.uploadLimit }}</td>
<td class="py-3 pr-4 text-gray-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td>
<td class="py-3 text-right">
<div class="flex justify-end gap-2">
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
</div> </div>
</td>
</tr>
</tbody>
</table>
</div> </div>
</AdminSectionShell> </AdminSectionShell>

View File

@@ -3,23 +3,32 @@ import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/app/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/app/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/app/AppInput.vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminUserRow = any; type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>;
type AdminUserRow = NonNullable<ListUsersResponse["users"]>[number];
const roleOptions = ["USER", "ADMIN"] as const;
const roleFilterOptions = ["", ...roleOptions] as const;
const loading = ref(true); const loading = ref(true);
const submitting = ref(false); const submitting = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const actionError = ref<string | null>(null); const actionError = ref<string | null>(null);
const rows = ref<AdminUserRow[]>([]); const rows = ref<AdminUserRow[]>([]);
const roleOptions = ["USER", "ADMIN"]; const total = ref(0);
const limit = ref(12);
const page = ref(1);
const selectedRow = ref<AdminUserRow | null>(null);
const search = ref("");
const appliedSearch = ref("");
const roleFilter = ref<(typeof roleFilterOptions)[number]>("");
const createOpen = ref(false); const createOpen = ref(false);
const editOpen = ref(false); const editOpen = ref(false);
const roleOpen = ref(false); const roleOpen = ref(false);
const deleteOpen = ref(false); const deleteOpen = ref(false);
const selectedRow = ref<AdminUserRow | null>(null);
const createForm = reactive({ const createForm = reactive({
email: "", email: "",
@@ -46,6 +55,27 @@ const roleForm = reactive({
const canCreate = computed(() => createForm.email.trim() && createForm.password.trim() && createForm.role.trim()); const canCreate = computed(() => createForm.email.trim() && createForm.password.trim() && createForm.role.trim());
const canUpdate = computed(() => editForm.id.trim() && editForm.email.trim() && editForm.role.trim()); const canUpdate = computed(() => editForm.id.trim() && editForm.email.trim() && editForm.role.trim());
const canUpdateRole = computed(() => roleForm.id.trim() && roleForm.role.trim()); const canUpdateRole = computed(() => roleForm.id.trim() && roleForm.role.trim());
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
return [
{ label: "Role", value: selectedRow.value.role || "USER" },
{ label: "Plan", value: selectedRow.value.planName || selectedRow.value.planId || "Free" },
{ label: "Videos", value: String(selectedRow.value.videoCount ?? 0) },
{ label: "Wallet", value: String(selectedRow.value.walletBalance ?? 0) },
{ label: "Created", value: formatDate(selectedRow.value.createdAt) },
];
});
const summary = computed(() => {
const adminCount = rows.value.filter((row) => String(row.role || "").toUpperCase() === "ADMIN").length;
return [
{ label: "Visible users", value: rows.value.length },
{ label: "Admin accounts", value: adminCount },
{ label: "Selected page", value: page.value },
{ label: "Total records", value: total.value },
];
});
const normalizeOptional = (value: string) => { const normalizeOptional = (value: string) => {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -65,16 +95,30 @@ const closeDialogs = () => {
editOpen.value = false; editOpen.value = false;
roleOpen.value = false; roleOpen.value = false;
deleteOpen.value = false; deleteOpen.value = false;
selectedRow.value = null;
actionError.value = null; actionError.value = null;
}; };
const syncSelectedRow = () => {
if (!selectedRow.value?.id) return;
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
if (fresh) selectedRow.value = fresh;
};
const loadUsers = async () => { const loadUsers = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await rpcClient.listAdminUsers({ page: 1, limit: 20 }); const response = await rpcClient.listAdminUsers({
page: page.value,
limit: limit.value,
search: appliedSearch.value.trim() || undefined,
role: roleFilter.value || undefined,
});
rows.value = response.users ?? []; rows.value = response.users ?? [];
total.value = response.total ?? rows.value.length;
limit.value = response.limit ?? limit.value;
page.value = response.page ?? page.value;
syncSelectedRow();
} catch (err: any) { } catch (err: any) {
error.value = err?.message || "Failed to load admin users"; error.value = err?.message || "Failed to load admin users";
} finally { } finally {
@@ -82,6 +126,12 @@ const loadUsers = async () => {
} }
}; };
const applyFilters = async () => {
page.value = 1;
appliedSearch.value = search.value;
await loadUsers();
};
const openEditDialog = (row: AdminUserRow) => { const openEditDialog = (row: AdminUserRow) => {
selectedRow.value = row; selectedRow.value = row;
actionError.value = null; actionError.value = null;
@@ -144,7 +194,6 @@ const submitEdit = async () => {
planId: normalizeOptional(editForm.planId), planId: normalizeOptional(editForm.planId),
}); });
editOpen.value = false; editOpen.value = false;
selectedRow.value = null;
await loadUsers(); await loadUsers();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to update user"; actionError.value = err?.message || "Failed to update user";
@@ -163,7 +212,6 @@ const submitRole = async () => {
role: roleForm.role, role: roleForm.role,
}); });
roleOpen.value = false; roleOpen.value = false;
selectedRow.value = null;
await loadUsers(); await loadUsers();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to update role"; actionError.value = err?.message || "Failed to update role";
@@ -180,6 +228,7 @@ const submitDelete = async () => {
await rpcClient.deleteAdminUser({ id: selectedRow.value.id }); await rpcClient.deleteAdminUser({ id: selectedRow.value.id });
deleteOpen.value = false; deleteOpen.value = false;
selectedRow.value = null; selectedRow.value = null;
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
await loadUsers(); await loadUsers();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to delete user"; actionError.value = err?.message || "Failed to delete user";
@@ -188,50 +237,154 @@ const submitDelete = async () => {
} }
}; };
const previousPage = async () => {
if (page.value <= 1) return;
page.value -= 1;
await loadUsers();
};
const nextPage = async () => {
if (page.value >= totalPages.value) return;
page.value += 1;
await loadUsers();
};
const formatDate = (value?: string) => {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const roleBadgeClass = (role?: string) => {
const normalized = String(role || "USER").toUpperCase();
if (normalized === "ADMIN") return "border-sky-200 bg-sky-50 text-sky-700";
return "border-slate-200 bg-slate-100 text-slate-700";
};
watch(roleFilter, async () => {
page.value = 1;
await loadUsers();
});
onMounted(loadUsers); onMounted(loadUsers);
</script> </script>
<template> <template>
<AdminSectionShell <AdminSectionShell
title="Admin Users" title="Admin Users"
description="User management data from admin gRPC service." description="Manage account lifecycle, plan assignments and moderation from the current admin RPC contract."
eyebrow="Identity"
:badge="`${total} total users`"
> >
<div class="mb-4 flex justify-end"> <template #toolbar>
<AppButton size="sm" variant="secondary" @click="loadUsers">Refresh</AppButton>
<AppButton size="sm" @click="actionError = null; createOpen = true">Create user</AppButton> <AppButton size="sm" @click="actionError = null; createOpen = true">Create user</AppButton>
</template>
<template #stats>
<div
v-for="item in summary"
:key="item.label"
class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4"
>
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ item.label }}</div>
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
</div>
</template>
<template #aside>
<div class="space-y-5">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected user</div>
<div v-if="selectedRow" class="mt-3 space-y-4">
<div>
<div class="text-lg font-semibold text-white">{{ selectedRow.email }}</div>
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.username ? `@${selectedRow.username}` : selectedRow.id }}</div>
</div>
<div class="grid gap-3">
<div v-for="item in selectedMeta" :key="item.label" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</div>
<div class="mt-1 text-sm font-medium text-white">{{ item.value }}</div>
</div>
</div>
<div class="grid gap-2">
<AppButton size="sm" @click="openEditDialog(selectedRow)">Edit profile</AppButton>
<AppButton size="sm" variant="secondary" @click="openRoleDialog(selectedRow)">Change role</AppButton>
<AppButton size="sm" variant="danger" @click="openDeleteDialog(selectedRow)">Delete user</AppButton>
</div>
</div>
<div v-else class="mt-3 rounded-2xl border border-dashed border-white/15 px-4 py-5 text-sm leading-6 text-slate-400">
Pick a row to inspect account metadata and trigger actions without leaving the list.
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-slate-50/80 p-4 lg:flex-row lg:items-end lg:justify-between">
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px] lg:min-w-[560px]">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Search</label>
<AppInput v-model="search" placeholder="Search by email or username" @enter="applyFilters" />
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Role filter</label>
<select v-model="roleFilter" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
<option v-for="role in roleFilterOptions" :key="role || 'all'" :value="role">{{ role || 'ALL' }}</option>
</select>
</div>
</div>
<div class="flex items-center gap-2">
<AppButton size="sm" variant="ghost" @click="search = ''; appliedSearch = ''; roleFilter = ''; loadUsers()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply filters</AppButton>
</div>
</div> </div>
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ error }} {{ error }}
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else class="overflow-hidden rounded-2xl border border-slate-200">
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm"> <table class="min-w-full text-left text-sm">
<thead> <thead class="bg-slate-50/90 text-slate-500">
<tr class="border-b border-gray-200 text-gray-500"> <tr>
<th class="py-3 pr-4 font-medium">ID</th> <th class="px-4 py-3 font-semibold">User</th>
<th class="py-3 pr-4 font-medium">Username</th> <th class="px-4 py-3 font-semibold">Role</th>
<th class="py-3 pr-4 font-medium">Email</th> <th class="px-4 py-3 font-semibold">Plan</th>
<th class="py-3 pr-4 font-medium">Role</th> <th class="px-4 py-3 font-semibold">Videos</th>
<th class="py-3 pr-4 font-medium">Plan</th> <th class="px-4 py-3 font-semibold">Created</th>
<th class="py-3 pr-4 font-medium">Videos</th> <th class="px-4 py-3 text-right font-semibold">Actions</th>
<th class="py-3 pr-4 text-right font-medium">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading" class="border-b border-gray-100"> <tr v-if="loading" class="border-t border-slate-200">
<td colspan="7" class="py-6 text-center text-gray-500">Loading users...</td> <td colspan="6" class="px-4 py-10 text-center text-slate-500">Loading users...</td>
</tr> </tr>
<tr v-else-if="rows.length === 0" class="border-b border-gray-100"> <tr v-else-if="rows.length === 0" class="border-t border-slate-200">
<td colspan="7" class="py-6 text-center text-gray-500">No users found.</td> <td colspan="6" class="px-4 py-10 text-center text-slate-500">No users matched the current filters.</td>
</tr> </tr>
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top"> <tr
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td> v-for="row in rows"
<td class="py-3 pr-4 text-gray-700">{{ row.username || '—' }}</td> :key="row.id"
<td class="py-3 pr-4 text-gray-700">{{ row.email }}</td> class="border-t border-slate-200 transition-colors hover:bg-slate-50/70"
<td class="py-3 pr-4 text-gray-700">{{ row.role || 'USER' }}</td> :class="selectedRow?.id === row.id ? 'bg-sky-50/60' : ''"
<td class="py-3 pr-4 text-gray-700">{{ row.planName || row.planId || '—' }}</td> >
<td class="py-3 pr-4 text-gray-700">{{ row.videoCount ?? 0 }}</td> <td class="px-4 py-3">
<td class="py-3 text-right"> <button class="text-left" @click="selectedRow = row">
<div class="font-medium text-slate-900">{{ row.email }}</div>
<div class="mt-1 text-xs text-slate-500">{{ row.username ? `@${row.username}` : row.id }}</div>
</button>
</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="roleBadgeClass(row.role)">
{{ row.role || 'USER' }}
</span>
</td>
<td class="px-4 py-3 text-slate-700">{{ row.planName || row.planId || 'Free' }}</td>
<td class="px-4 py-3 text-slate-700">{{ row.videoCount ?? 0 }}</td>
<td class="px-4 py-3 text-slate-500">{{ formatDate(row.createdAt) }}</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton> <AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
<AppButton size="sm" variant="ghost" @click="openRoleDialog(row)">Role</AppButton> <AppButton size="sm" variant="ghost" @click="openRoleDialog(row)">Role</AppButton>
@@ -242,9 +395,21 @@ onMounted(loadUsers);
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex flex-col gap-3 border-t border-slate-200 bg-slate-50/70 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div class="text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
Page {{ page }} of {{ totalPages }} · {{ total }} records
</div>
<div class="flex items-center gap-2">
<AppButton size="sm" variant="secondary" :disabled="page <= 1 || loading" @click="previousPage">Previous</AppButton>
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
</div>
</div>
</div>
</div>
</AdminSectionShell> </AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create admin user" maxWidthClass="max-w-lg" @close="actionError = null"> <AppDialog v-model:visible="createOpen" title="Create admin user" maxWidthClass="max-w-2xl" @close="actionError = null">
<div class="space-y-4"> <div class="space-y-4">
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div> <div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
@@ -280,7 +445,7 @@ onMounted(loadUsers);
</template> </template>
</AppDialog> </AppDialog>
<AppDialog v-model:visible="editOpen" title="Edit user" maxWidthClass="max-w-lg" @close="actionError = null"> <AppDialog v-model:visible="editOpen" title="Edit user" maxWidthClass="max-w-2xl" @close="actionError = null">
<div class="space-y-4"> <div class="space-y-4">
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div> <div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">

View File

@@ -3,21 +3,32 @@ import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/app/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/app/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/app/AppInput.vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminVideoRow = any; type ListVideosResponse = Awaited<ReturnType<typeof rpcClient.listAdminVideos>>;
type AdminVideoRow = NonNullable<ListVideosResponse["videos"]>[number];
const statusOptions = ["UPLOADED", "PROCESSING", "READY", "FAILED"] as const;
const statusFilterOptions = ["", ...statusOptions] as const;
const loading = ref(true); const loading = ref(true);
const submitting = ref(false); const submitting = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const actionError = ref<string | null>(null); const actionError = ref<string | null>(null);
const rows = ref<AdminVideoRow[]>([]); const rows = ref<AdminVideoRow[]>([]);
const total = ref(0);
const limit = ref(12);
const page = ref(1);
const selectedRow = ref<AdminVideoRow | null>(null); const selectedRow = ref<AdminVideoRow | null>(null);
const search = ref("");
const appliedSearch = ref("");
const ownerFilter = ref("");
const appliedOwnerFilter = ref("");
const statusFilter = ref<(typeof statusFilterOptions)[number]>("");
const createOpen = ref(false); const createOpen = ref(false);
const editOpen = ref(false); const editOpen = ref(false);
const deleteOpen = ref(false); const deleteOpen = ref(false);
const statusOptions = ["UPLOADED", "PROCESSING", "READY", "FAILED"];
const createForm = reactive({ const createForm = reactive({
userId: "", userId: "",
@@ -44,6 +55,27 @@ const editForm = reactive({
adTemplateId: "", adTemplateId: "",
}); });
const canCreate = computed(() => createForm.userId.trim() && createForm.title.trim() && createForm.url.trim() && createForm.status.trim());
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.title.trim() && editForm.url.trim() && editForm.status.trim());
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / limit.value)));
const summary = computed(() => [
{ label: "Visible videos", value: rows.value.length },
{ label: "Ready assets", value: rows.value.filter((row) => row.status === "READY").length },
{ label: "Processing", value: rows.value.filter((row) => row.status === "PROCESSING").length },
{ label: "Total records", value: total.value },
]);
const selectedMeta = computed(() => {
if (!selectedRow.value) return [];
return [
{ label: "Owner", value: selectedRow.value.ownerEmail || selectedRow.value.userId || "—" },
{ label: "Status", value: selectedRow.value.status || "—" },
{ label: "Format", value: selectedRow.value.format || "—" },
{ label: "Size", value: formatBytes(selectedRow.value.size) },
{ label: "Duration", value: formatDuration(selectedRow.value.duration) },
{ label: "Updated", value: formatDate(selectedRow.value.updatedAt || selectedRow.value.createdAt) },
];
});
const normalizeOptional = (value: string) => { const normalizeOptional = (value: string) => {
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed ? trimmed : undefined; return trimmed ? trimmed : undefined;
@@ -51,9 +83,6 @@ const normalizeOptional = (value: string) => {
const normalizeNumber = (value: number | null) => (value == null ? undefined : value); const normalizeNumber = (value: number | null) => (value == null ? undefined : value);
const canCreate = computed(() => createForm.userId.trim() && createForm.title.trim() && createForm.url.trim() && createForm.status.trim());
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.title.trim() && editForm.url.trim() && editForm.status.trim());
const resetCreateForm = () => { const resetCreateForm = () => {
createForm.userId = ""; createForm.userId = "";
createForm.title = ""; createForm.title = "";
@@ -70,16 +99,31 @@ const closeDialogs = () => {
createOpen.value = false; createOpen.value = false;
editOpen.value = false; editOpen.value = false;
deleteOpen.value = false; deleteOpen.value = false;
selectedRow.value = null;
actionError.value = null; actionError.value = null;
}; };
const syncSelectedRow = () => {
if (!selectedRow.value?.id) return;
const fresh = rows.value.find((row) => row.id === selectedRow.value?.id);
if (fresh) selectedRow.value = fresh;
};
const loadVideos = async () => { const loadVideos = async () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await rpcClient.listAdminVideos({ page: 1, limit: 20 }); const response = await rpcClient.listAdminVideos({
page: page.value,
limit: limit.value,
search: appliedSearch.value.trim() || undefined,
userId: appliedOwnerFilter.value.trim() || undefined,
status: statusFilter.value || undefined,
});
rows.value = response.videos ?? []; rows.value = response.videos ?? [];
total.value = response.total ?? rows.value.length;
limit.value = response.limit ?? limit.value;
page.value = response.page ?? page.value;
syncSelectedRow();
} catch (err: any) { } catch (err: any) {
error.value = err?.message || "Failed to load admin videos"; error.value = err?.message || "Failed to load admin videos";
} finally { } finally {
@@ -87,6 +131,13 @@ const loadVideos = async () => {
} }
}; };
const applyFilters = async () => {
page.value = 1;
appliedSearch.value = search.value;
appliedOwnerFilter.value = ownerFilter.value;
await loadVideos();
};
const openEditDialog = (row: AdminVideoRow) => { const openEditDialog = (row: AdminVideoRow) => {
selectedRow.value = row; selectedRow.value = row;
actionError.value = null; actionError.value = null;
@@ -153,7 +204,6 @@ const submitEdit = async () => {
adTemplateId: normalizeOptional(editForm.adTemplateId), adTemplateId: normalizeOptional(editForm.adTemplateId),
}); });
editOpen.value = false; editOpen.value = false;
selectedRow.value = null;
await loadVideos(); await loadVideos();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to update video"; actionError.value = err?.message || "Failed to update video";
@@ -170,6 +220,7 @@ const submitDelete = async () => {
await rpcClient.deleteAdminVideo({ id: selectedRow.value.id }); await rpcClient.deleteAdminVideo({ id: selectedRow.value.id });
deleteOpen.value = false; deleteOpen.value = false;
selectedRow.value = null; selectedRow.value = null;
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
await loadVideos(); await loadVideos();
} catch (err: any) { } catch (err: any) {
actionError.value = err?.message || "Failed to delete video"; actionError.value = err?.message || "Failed to delete video";
@@ -178,50 +229,174 @@ const submitDelete = async () => {
} }
}; };
const previousPage = async () => {
if (page.value <= 1) return;
page.value -= 1;
await loadVideos();
};
const nextPage = async () => {
if (page.value >= totalPages.value) return;
page.value += 1;
await loadVideos();
};
const formatDate = (value?: string) => {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const formatBytes = (value?: number) => {
const bytes = Number(value || 0);
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const normalized = bytes / 1024 ** index;
return `${normalized.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
};
const formatDuration = (value?: number) => {
const seconds = Number(value || 0);
if (!seconds) return "—";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${String(secs).padStart(2, "0")}`;
};
const statusBadgeClass = (status?: string) => {
switch (status) {
case "READY":
return "border-emerald-200 bg-emerald-50 text-emerald-700";
case "PROCESSING":
return "border-amber-200 bg-amber-50 text-amber-700";
case "FAILED":
return "border-rose-200 bg-rose-50 text-rose-700";
default:
return "border-slate-200 bg-slate-100 text-slate-700";
}
};
watch(statusFilter, async () => {
page.value = 1;
await loadVideos();
});
onMounted(loadVideos); onMounted(loadVideos);
</script> </script>
<template> <template>
<AdminSectionShell <AdminSectionShell
title="Admin Videos" title="Admin Videos"
description="Cross-user video list from admin gRPC service." description="Cross-user video inventory with direct edit, moderation and storage context."
eyebrow="Media"
:badge="`${total} total videos`"
> >
<div class="mb-4 flex justify-end"> <template #toolbar>
<AppButton size="sm" variant="secondary" @click="loadVideos">Refresh</AppButton>
<AppButton size="sm" @click="actionError = null; createOpen = true">Create video</AppButton> <AppButton size="sm" @click="actionError = null; createOpen = true">Create video</AppButton>
</template>
<template #stats>
<div v-for="item in summary" :key="item.label" class="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ item.label }}</div>
<div class="mt-2 text-2xl font-semibold tracking-tight text-slate-950">{{ item.value }}</div>
</div>
</template>
<template #aside>
<div class="space-y-5">
<div class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Selected video</div>
<div v-if="selectedRow" class="space-y-4">
<div>
<div class="text-lg font-semibold text-white">{{ selectedRow.title }}</div>
<div class="mt-1 text-sm text-slate-400">{{ selectedRow.id }}</div>
</div>
<div class="grid gap-3">
<div v-for="item in selectedMeta" :key="item.label" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</div>
<div class="mt-1 text-sm font-medium text-white">{{ item.value }}</div>
</div>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-slate-500">Source URL</div>
<div class="mt-2 break-all text-sm text-slate-200">{{ selectedRow.url }}</div>
</div>
<div class="grid gap-2">
<AppButton size="sm" @click="openEditDialog(selectedRow)">Edit video</AppButton>
<AppButton size="sm" variant="danger" @click="openDeleteDialog(selectedRow)">Delete video</AppButton>
</div>
</div>
<div v-else class="rounded-2xl border border-dashed border-white/15 px-4 py-5 text-sm leading-6 text-slate-400">
Select a video to review metadata, storage footprint and upstream source URL.
</div>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 rounded-2xl border border-slate-200 bg-slate-50/80 p-4 xl:grid-cols-[minmax(0,1fr)_220px_180px_auto] xl:items-end">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Search</label>
<AppInput v-model="search" placeholder="Search by title" @enter="applyFilters" />
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Owner user ID</label>
<AppInput v-model="ownerFilter" placeholder="Optional owner id" @enter="applyFilters" />
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Status</label>
<select v-model="statusFilter" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
<option v-for="status in statusFilterOptions" :key="status || 'all'" :value="status">{{ status || 'ALL' }}</option>
</select>
</div>
<div class="flex items-center gap-2 xl:justify-end">
<AppButton size="sm" variant="ghost" @click="search = ''; ownerFilter = ''; appliedSearch = ''; appliedOwnerFilter = ''; statusFilter = ''; loadVideos()">Reset</AppButton>
<AppButton size="sm" variant="secondary" @click="applyFilters">Apply</AppButton>
</div>
</div> </div>
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div v-if="error" class="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ error }} {{ error }}
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else class="overflow-hidden rounded-2xl border border-slate-200">
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm"> <table class="min-w-full text-left text-sm">
<thead> <thead class="bg-slate-50/90 text-slate-500">
<tr class="border-b border-gray-200 text-gray-500"> <tr>
<th class="py-3 pr-4 font-medium">ID</th> <th class="px-4 py-3 font-semibold">Video</th>
<th class="py-3 pr-4 font-medium">Title</th> <th class="px-4 py-3 font-semibold">Owner</th>
<th class="py-3 pr-4 font-medium">Owner</th> <th class="px-4 py-3 font-semibold">Status</th>
<th class="py-3 pr-4 font-medium">Status</th> <th class="px-4 py-3 font-semibold">Format</th>
<th class="py-3 pr-4 font-medium">Format</th> <th class="px-4 py-3 font-semibold">Size</th>
<th class="py-3 pr-4 font-medium">Size</th> <th class="px-4 py-3 font-semibold">Duration</th>
<th class="py-3 pr-4 text-right font-medium">Actions</th> <th class="px-4 py-3 text-right font-semibold">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading" class="border-b border-gray-100"> <tr v-if="loading" class="border-t border-slate-200">
<td colspan="7" class="py-6 text-center text-gray-500">Loading videos...</td> <td colspan="7" class="px-4 py-10 text-center text-slate-500">Loading videos...</td>
</tr> </tr>
<tr v-else-if="rows.length === 0" class="border-b border-gray-100"> <tr v-else-if="rows.length === 0" class="border-t border-slate-200">
<td colspan="7" class="py-6 text-center text-gray-500">No videos found.</td> <td colspan="7" class="px-4 py-10 text-center text-slate-500">No videos matched the current filters.</td>
</tr> </tr>
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top"> <tr v-for="row in rows" :key="row.id" class="border-t border-slate-200 transition-colors hover:bg-slate-50/70" :class="selectedRow?.id === row.id ? 'bg-sky-50/60' : ''">
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td> <td class="px-4 py-3">
<td class="py-3 pr-4 text-gray-700">{{ row.title }}</td> <button class="text-left" @click="selectedRow = row">
<td class="py-3 pr-4 text-gray-700">{{ row.ownerEmail || row.userId }}</td> <div class="font-medium text-slate-900">{{ row.title }}</div>
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td> <div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
<td class="py-3 pr-4 text-gray-700">{{ row.format || '—' }}</td> </button>
<td class="py-3 pr-4 text-gray-700">{{ row.size ?? 0 }}</td> </td>
<td class="py-3 text-right"> <td class="px-4 py-3 text-slate-700">{{ row.ownerEmail || row.userId }}</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" :class="statusBadgeClass(row.status)">
{{ row.status }}
</span>
</td>
<td class="px-4 py-3 text-slate-700">{{ row.format || '—' }}</td>
<td class="px-4 py-3 text-slate-700">{{ formatBytes(row.size) }}</td>
<td class="px-4 py-3 text-slate-700">{{ formatDuration(row.duration) }}</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton> <AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton> <AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
@@ -231,6 +406,16 @@ onMounted(loadVideos);
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex flex-col gap-3 border-t border-slate-200 bg-slate-50/70 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div class="text-xs font-medium uppercase tracking-[0.16em] text-slate-500">Page {{ page }} of {{ totalPages }} · {{ total }} records</div>
<div class="flex items-center gap-2">
<AppButton size="sm" variant="secondary" :disabled="page <= 1 || loading" @click="previousPage">Previous</AppButton>
<AppButton size="sm" variant="secondary" :disabled="page >= totalPages || loading" @click="nextPage">Next</AppButton>
</div>
</div>
</div>
</div>
</AdminSectionShell> </AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create admin video" maxWidthClass="max-w-2xl" @close="actionError = null"> <AppDialog v-model:visible="createOpen" title="Create admin video" maxWidthClass="max-w-2xl" @close="actionError = null">

View File

@@ -4,15 +4,44 @@ import PageHeader from "@/components/dashboard/PageHeader.vue";
defineProps<{ defineProps<{
title: string; title: string;
description: string; description: string;
eyebrow?: string;
badge?: string;
}>(); }>();
</script> </script>
<template> <template>
<section class="space-y-4"> <section class="space-y-5">
<div class="rounded-[28px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-5 shadow-[0_18px_60px_-36px_rgba(15,23,42,0.4)]">
<div class="mb-5 flex flex-col gap-4 border-b border-slate-200/80 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div class="min-w-0 flex-1">
<div class="mb-3 flex flex-wrap items-center gap-2">
<span v-if="eyebrow" class="inline-flex items-center rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-700">
{{ eyebrow }}
</span>
<span v-if="badge" class="inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] font-medium text-slate-600 shadow-sm">
{{ badge }}
</span>
</div>
<PageHeader :title="title" :description="description" /> <PageHeader :title="title" :description="description" />
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-6"> <div v-if="$slots.toolbar" class="flex flex-wrap items-center gap-2 lg:justify-end">
<slot name="toolbar" />
</div>
</div>
<div v-if="$slots.stats" class="mb-5 grid gap-3 border-b border-slate-200/80 pb-5 md:grid-cols-2 xl:grid-cols-4">
<slot name="stats" />
</div>
<div :class="$slots.aside ? 'grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]' : ''">
<div class="min-w-0 rounded-[24px] border border-slate-200/80 bg-white/90 p-5 shadow-[0_12px_40px_-32px_rgba(15,23,42,0.45)]">
<slot /> <slot />
</div> </div>
<aside v-if="$slots.aside" class="min-w-0 rounded-[24px] border border-slate-200/80 bg-slate-950 p-5 text-slate-100 shadow-[0_18px_50px_-30px_rgba(2,6,23,0.8)]">
<slot name="aside" />
</aside>
</div>
</div>
</section> </section>
</template> </template>

View File

@@ -1,4 +1,3 @@
import { RedisClient } from "bun";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { contextStorage } from "hono/context-storage"; import { contextStorage } from "hono/context-storage";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
@@ -10,11 +9,18 @@ type AppFetch = (
requestInit?: RequestInit requestInit?: RequestInit
) => Response | Promise<Response>; ) => Response | Promise<Response>;
type RedisClientLike = {
connect(): Promise<unknown>;
get(key: string): Promise<string | null>;
set(...args: unknown[]): Promise<unknown> | unknown;
del(key: string): Promise<unknown> | unknown;
};
declare module "hono" { declare module "hono" {
interface ContextVariableMap { interface ContextVariableMap {
fetch: AppFetch; fetch: AppFetch;
isMobile: boolean; isMobile: boolean;
redis: RedisClient; redis: RedisClientLike;
jwtProvider: JwtProvider; jwtProvider: JwtProvider;
jwtPayload: Record<string, unknown>; jwtPayload: Record<string, unknown>;
userId: string; userId: string;
@@ -23,7 +29,37 @@ declare module "hono" {
} }
} }
const redisClient = new RedisClient("redis://:pass123@47.84.62.226:6379/3"); let redisClientPromise: Promise<RedisClientLike> | null = null;
const getJwtSecret = () => {
const secret = (process.env.JWT_SECRET || process.env.STREAM_UI_JWT_SECRET || "").trim() || "secret_is_not_configured"
if (!secret) {
throw new Error("JWT secret is not configured");
}
return secret;
};
const getRedisUrl = () => {
const redisUrl = (process.env.REDIS_URL || process.env.STREAM_UI_REDIS_URL || "").trim() || "redis://:pass123@47.84.62.226:6379/3";
if (!redisUrl) {
throw new Error("Redis URL is not configured");
}
return redisUrl;
};
const getRedisClient = async (): Promise<RedisClientLike> => {
console.log("bun", typeof Bun)
if (!redisClientPromise) {
// redisClientPromise = import("Bun").then(async ({ RedisClient }) => {
// const client = new RedisClient(getRedisUrl()) as RedisClientLike;
// await client.connect();
// return client;
// });
}
// return await redisClientPromise;
return Promise.resolve() as any
};
export function setupMiddlewares(app: Hono) { export function setupMiddlewares(app: Hono) {
app.use( app.use(
@@ -37,7 +73,7 @@ export function setupMiddlewares(app: Hono) {
}), }),
contextStorage(), contextStorage(),
async (c, next) => { async (c, next) => {
c.set("jwtProvider", JwtProvider.newJWTProvider("your-secret-key")); c.set("jwtProvider", JwtProvider.newJWTProvider(getJwtSecret()));
await next(); await next();
} }
); );
@@ -55,7 +91,9 @@ export function setupMiddlewares(app: Hono) {
}); });
app.use(async (c, next) => { app.use(async (c, next) => {
try { try {
return await redisClient.connect().then(() => c.set("redis", redisClient)).then(next) const redisClient = await getRedisClient();
c.set("redis", redisClient);
await next();
} catch (e) { } catch (e) {
console.error("Failed to connect to Redis", e); console.error("Failed to connect to Redis", e);
return c.json({ error: "Redis unavailable" }, 500); return c.json({ error: "Redis unavailable" }, 500);