develop-updateui #1
@@ -44,7 +44,7 @@ const links = computed<Record<string, any>>(() => {
|
||||
...baseLinks,
|
||||
{
|
||||
href: "/admin/overview",
|
||||
label: "Admin",
|
||||
label: "Admin Console",
|
||||
icon: LayoutDashboard,
|
||||
action: null,
|
||||
className,
|
||||
|
||||
@@ -6,18 +6,27 @@ import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "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 submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminAdTemplateRow[]>([]);
|
||||
const total = ref(0);
|
||||
const limit = ref(12);
|
||||
const page = ref(1);
|
||||
const selectedRow = ref<AdminAdTemplateRow | null>(null);
|
||||
const search = ref("");
|
||||
const appliedSearch = ref("");
|
||||
const ownerFilter = ref("");
|
||||
const appliedOwnerFilter = ref("");
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const formatOptions = ["pre-roll", "mid-roll", "post-roll"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
@@ -44,13 +53,43 @@ const editForm = reactive({
|
||||
|
||||
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 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 () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
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 ?? [];
|
||||
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) {
|
||||
error.value = err?.message || "Failed to load admin ad templates";
|
||||
} finally {
|
||||
@@ -73,10 +112,16 @@ const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.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) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -140,7 +185,6 @@ const submitEdit = async () => {
|
||||
isDefault: editForm.isDefault,
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadTemplates();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update ad template";
|
||||
@@ -157,6 +201,7 @@ const submitDelete = async () => {
|
||||
await rpcClient.deleteAdminAdTemplate({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
|
||||
await loadTemplates();
|
||||
} catch (err: any) {
|
||||
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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
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>
|
||||
</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 v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
<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-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">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Name</th>
|
||||
<th class="py-3 pr-4 font-medium">Owner</th>
|
||||
<th class="py-3 pr-4 font-medium">Format</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Default</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
<thead class="bg-slate-50/90 text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold">Template</th>
|
||||
<th class="px-4 py-3 font-semibold">Owner</th>
|
||||
<th class="px-4 py-3 font-semibold">Format</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Default</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="6" class="py-6 text-center text-gray-500">Loading ad templates...</td>
|
||||
<tr v-if="loading" class="border-t border-slate-200">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-slate-500">Loading ad templates...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="6" class="py-6 text-center text-gray-500">No ad templates found.</td>
|
||||
<tr v-else-if="rows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-slate-500">No templates matched the current filters.</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.vastTagUrl }}</div>
|
||||
<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="px-4 py-3">
|
||||
<button class="text-left" @click="selectedRow = row">
|
||||
<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 class="py-3 pr-4 text-gray-700">{{ row.ownerEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.adFormat || 'pre-roll' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.isDefault ? 'YES' : 'NO' }}</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 text-slate-700">{{ row.adFormat || 'pre-roll' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.isDefault ? 'YES' : 'NO' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<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>
|
||||
@@ -219,6 +338,16 @@ onMounted(loadTemplates);
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create ad template" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
|
||||
@@ -3,10 +3,11 @@ import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "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 submitting = ref(false);
|
||||
@@ -16,6 +17,27 @@ const rows = ref<AdminAgentRow[]>([]);
|
||||
const selectedRow = ref<AdminAgentRow | null>(null);
|
||||
const restartOpen = 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 () => {
|
||||
loading.value = true;
|
||||
@@ -23,6 +45,10 @@ const loadAgents = async () => {
|
||||
try {
|
||||
const response = await rpcClient.listAdminAgents();
|
||||
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) {
|
||||
error.value = err?.message || "Failed to load admin agents";
|
||||
} finally {
|
||||
@@ -33,10 +59,18 @@ const loadAgents = async () => {
|
||||
const closeDialogs = () => {
|
||||
restartOpen.value = false;
|
||||
updateOpen.value = false;
|
||||
selectedRow.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) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -56,7 +90,6 @@ const submitRestart = async () => {
|
||||
try {
|
||||
await rpcClient.restartAdminAgent({ id: selectedRow.value.id });
|
||||
restartOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadAgents();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to restart agent";
|
||||
@@ -72,7 +105,6 @@ const submitUpdate = async () => {
|
||||
try {
|
||||
await rpcClient.updateAdminAgent({ id: selectedRow.value.id });
|
||||
updateOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadAgents();
|
||||
} catch (err: any) {
|
||||
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 }) => {
|
||||
if (topic !== "picpic/events" || payload?.type !== "agent_update") return;
|
||||
if (topic !== "picpic/events") return;
|
||||
|
||||
if (payload?.type === "agent_update") {
|
||||
const update = payload.payload;
|
||||
if (!update?.id) return;
|
||||
const row = rows.value.find((item) => item.id === update.id);
|
||||
@@ -97,6 +148,23 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
} else {
|
||||
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);
|
||||
@@ -105,49 +173,100 @@ onMounted(loadAgents);
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
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">
|
||||
<AppButton size="sm" variant="secondary" @click="loadAgents">Refresh agents</AppButton>
|
||||
</div>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadAgents">Refresh agents</AppButton>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Agent</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Platform</th>
|
||||
<th class="py-3 pr-4 font-medium">Version</th>
|
||||
<th class="py-3 pr-4 font-medium">CPU</th>
|
||||
<th class="py-3 pr-4 font-medium">RAM</th>
|
||||
<th class="py-3 pr-4 font-medium">Heartbeat</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
<thead class="bg-slate-50/90 text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold">Agent</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Capacity</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Active jobs</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">CPU</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">RAM</th>
|
||||
<th class="px-4 py-3 font-semibold">Heartbeat</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="8" class="py-6 text-center text-gray-500">Loading agents...</td>
|
||||
<tr v-if="loading" class="border-t border-slate-200">
|
||||
<td colspan="8" class="px-4 py-10 text-center text-slate-500">Loading agents...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="8" class="py-6 text-center text-gray-500">No agents connected.</td>
|
||||
<tr v-else-if="rows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="8" class="px-4 py-10 text-center text-slate-500">No agents connected.</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 || row.id }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.id }}</div>
|
||||
<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="px-4 py-3">
|
||||
<button class="text-left" @click="selectedRow = row">
|
||||
<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 class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.platform || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.version || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.cpu ?? 0 }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.ram ?? 0 }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.lastHeartbeat ? new Date(row.lastHeartbeat).toLocaleString() : '—' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<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 || 'UNKNOWN' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-slate-700">{{ row.capacity ?? 0 }}</td>
|
||||
<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">
|
||||
<AppButton size="sm" variant="secondary" @click="openUpdateDialog(row)">Update</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openRestartDialog(row)">Restart</AppButton>
|
||||
@@ -157,6 +276,8 @@ onMounted(loadAgents);
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import { computed, onMounted, reactive, ref } from "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 submitting = ref(false);
|
||||
@@ -16,11 +17,13 @@ const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminJobRow[]>([]);
|
||||
const selectedRow = ref<AdminJobRow | null>(null);
|
||||
const selectedLogs = ref("");
|
||||
const activeAgentFilter = ref("");
|
||||
const appliedAgentFilter = ref("");
|
||||
const search = ref("");
|
||||
const createOpen = ref(false);
|
||||
const logsOpen = ref(false);
|
||||
const cancelOpen = ref(false);
|
||||
const retryOpen = ref(false);
|
||||
const activeAgentFilter = ref("");
|
||||
|
||||
const createForm = reactive({
|
||||
command: "",
|
||||
@@ -42,14 +45,46 @@ const parseEnvText = (value: string) =>
|
||||
if (separatorIndex === -1) return acc;
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const val = line.slice(separatorIndex + 1).trim();
|
||||
if (key) {
|
||||
acc[key] = val;
|
||||
}
|
||||
if (key) acc[key] = val;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const hasEnv = computed(() => Object.keys(parseEnvText(createForm.envText)).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 = () => {
|
||||
createForm.command = "";
|
||||
@@ -66,11 +101,15 @@ const closeDialogs = () => {
|
||||
logsOpen.value = false;
|
||||
cancelOpen.value = false;
|
||||
retryOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
selectedLogs.value = "";
|
||||
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 () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -78,9 +117,10 @@ const loadJobs = async () => {
|
||||
const response = await rpcClient.listAdminJobs({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
agentId: activeAgentFilter.value.trim() || undefined,
|
||||
agentId: appliedAgentFilter.value.trim() || undefined,
|
||||
});
|
||||
rows.value = response.jobs ?? [];
|
||||
syncSelectedRow();
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin jobs";
|
||||
} 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) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
selectedLogs.value = "Loading logs...";
|
||||
logsOpen.value = true;
|
||||
try {
|
||||
const response = await rpcClient.getAdminJobLogs({ id: row.id });
|
||||
selectedLogs.value = response.logs || "No logs available.";
|
||||
await loadSelectedLogs(row.id);
|
||||
} catch (err: any) {
|
||||
selectedLogs.value = "";
|
||||
actionError.value = err?.message || "Failed to load job logs";
|
||||
@@ -145,7 +204,6 @@ const submitCancel = async () => {
|
||||
try {
|
||||
await rpcClient.cancelAdminJob({ id: selectedRow.value.id });
|
||||
cancelOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadJobs();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to cancel job";
|
||||
@@ -161,7 +219,6 @@ const submitRetry = async () => {
|
||||
try {
|
||||
await rpcClient.retryAdminJob({ id: selectedRow.value.id });
|
||||
retryOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadJobs();
|
||||
} catch (err: any) {
|
||||
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 }) => {
|
||||
if (topic.startsWith("picpic/job/") && payload?.type === "job_update") {
|
||||
const update = payload.payload;
|
||||
@@ -188,12 +262,14 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (topic.startsWith("picpic/logs/") && payload?.job_id) {
|
||||
const row = rows.value.find((item) => item.id === payload.job_id);
|
||||
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.updatedAt = new Date().toISOString();
|
||||
}
|
||||
if (selectedRow.value?.id === payload.job_id && typeof payload.line === "string") {
|
||||
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>
|
||||
<AdminSectionShell
|
||||
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">
|
||||
<div class="w-full max-w-sm space-y-2">
|
||||
<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>
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadJobs">Refresh</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 v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
<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-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">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Name</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Agent</th>
|
||||
<th class="py-3 pr-4 font-medium">Priority</th>
|
||||
<th class="py-3 pr-4 font-medium">Progress</th>
|
||||
<th class="py-3 pr-4 font-medium">Updated</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
<thead class="bg-slate-50/90 text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold">Job</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Agent</th>
|
||||
<th class="px-4 py-3 font-semibold">Priority</th>
|
||||
<th class="px-4 py-3 font-semibold">Progress</th>
|
||||
<th class="px-4 py-3 font-semibold">Updated</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">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 jobs...</td>
|
||||
<tr v-if="loading" class="border-t border-slate-200">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-500">Loading jobs...</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 jobs found.</td>
|
||||
<tr v-else-if="filteredRows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-500">No jobs matched the current filters.</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 || row.id }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.id }}</div>
|
||||
<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="px-4 py-3">
|
||||
<button class="text-left" @click="selectRow(row)">
|
||||
<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 class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.agentId || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.priority }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.progress || 0 }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.updatedAt ? new Date(row.updatedAt).toLocaleString() : '—' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<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 || 'UNKNOWN' }}
|
||||
</span>
|
||||
</td>
|
||||
<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">
|
||||
<AppButton size="sm" variant="secondary" @click="openLogsDialog(row)">Logs</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="openRetryDialog(row)">Retry</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openCancelDialog(row)">Cancel</AppButton>
|
||||
<AppButton v-if="isRetryable(row)" size="sm" @click="openRetryDialog(row)">Retry</AppButton>
|
||||
<AppButton v-if="isCancelable(row)" size="sm" variant="danger" @click="openCancelDialog(row)">Cancel</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<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="space-y-2 md:col-span-2">
|
||||
<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 class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Image</label>
|
||||
@@ -316,10 +451,10 @@ onMounted(loadJobs);
|
||||
</template>
|
||||
</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 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.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -3,34 +3,56 @@ import { client as rpcClient } from "@/api/rpcclient";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const jobId = ref("");
|
||||
const activeJobId = ref("");
|
||||
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 () => {
|
||||
if (!jobId.value.trim()) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
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.";
|
||||
liveLineCount.value = logs.value === "No logs available." ? 0 : countLogLines(logs.value);
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load logs";
|
||||
logs.value = "";
|
||||
activeJobId.value = "";
|
||||
liveLineCount.value = 0;
|
||||
} finally {
|
||||
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 }) => {
|
||||
if (!jobId.value.trim()) return;
|
||||
if (topic === `picpic/logs/${jobId.value.trim()}` && payload?.job_id === jobId.value.trim() && typeof payload.line === "string") {
|
||||
if (!activeJobId.value) return;
|
||||
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`;
|
||||
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>
|
||||
@@ -38,22 +60,58 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
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">
|
||||
<div class="w-full max-w-xl space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Job ID</label>
|
||||
<AppInput v-model="jobId" placeholder="job-..." />
|
||||
<template #toolbar>
|
||||
<AppButton size="sm" variant="secondary" :loading="loading" @click="loadLogs">Load logs</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 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>
|
||||
<AppButton size="sm" :loading="loading" @click="loadLogs">Load logs</AppButton>
|
||||
</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 }}
|
||||
</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">
|
||||
{{ loading ? 'Loading logs...' : logs }}
|
||||
<div class="rounded-[24px] border border-slate-200 bg-slate-950 p-4 shadow-[0_12px_40px_-32px_rgba(15,23,42,0.6)]">
|
||||
<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>
|
||||
</AdminSectionShell>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
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 AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminDashboard = Awaited<ReturnType<typeof rpcClient.getAdminDashboard>>;
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const dashboard = ref<any | null>(null);
|
||||
const dashboard = ref<AdminDashboard | null>(null);
|
||||
|
||||
const cards = computed(() => {
|
||||
const data = dashboard.value;
|
||||
return [
|
||||
{ title: "Total users", value: data?.totalUsers ?? 0, color: "primary" as const },
|
||||
{ title: "Total videos", value: data?.totalVideos ?? 0, color: "info" as const },
|
||||
{ title: "Payments", value: data?.totalPayments ?? 0, color: "success" as const },
|
||||
{ title: "Revenue", value: data?.totalRevenue ?? 0, color: "warning" as const },
|
||||
{ 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 },
|
||||
{ title: "Total users", value: data?.totalUsers ?? 0, note: `${data?.newUsersToday ?? 0} new today` },
|
||||
{ title: "Total videos", value: data?.totalVideos ?? 0, note: `${data?.newVideosToday ?? 0} new today` },
|
||||
{ title: "Payments", value: data?.totalPayments ?? 0, note: "Completed finance events" },
|
||||
{ title: "Revenue", value: data?.totalRevenue ?? 0, note: "Lifetime gross amount" },
|
||||
];
|
||||
});
|
||||
|
||||
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 {
|
||||
dashboard.value = await rpcClient.getAdminDashboard();
|
||||
} catch (err: any) {
|
||||
@@ -30,26 +49,70 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(loadDashboard);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
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 }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatsCard
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:title="card.title"
|
||||
:value="loading ? 0 : card.value"
|
||||
:color="card.color"
|
||||
/>
|
||||
<div v-else class="space-y-5">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<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)]">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{{ card.title }}</div>
|
||||
<div class="mt-3 text-3xl font-semibold tracking-tight text-slate-950">{{ loading ? '—' : card.value }}</div>
|
||||
<div class="mt-2 text-sm text-slate-500">{{ card.note }}</div>
|
||||
</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>
|
||||
</AdminSectionShell>
|
||||
</template>
|
||||
|
||||
@@ -3,23 +3,31 @@ import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.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";
|
||||
|
||||
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 submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminPaymentRow[]>([]);
|
||||
const total = ref(0);
|
||||
const limit = ref(12);
|
||||
const page = ref(1);
|
||||
const selectedRow = ref<AdminPaymentRow | null>(null);
|
||||
const userFilter = ref("");
|
||||
const appliedUserFilter = ref("");
|
||||
const statusFilter = ref<(typeof statusFilterOptions)[number]>("");
|
||||
const createOpen = ref(false);
|
||||
const statusOpen = ref(false);
|
||||
|
||||
const paymentMethodOptions = ["TOPUP", "WALLET"];
|
||||
const statusOptions = ["PENDING", "SUCCESS", "FAILED", "CANCELLED"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
planId: "",
|
||||
@@ -35,13 +43,43 @@ const statusForm = reactive({
|
||||
|
||||
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 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 () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
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 ?? [];
|
||||
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) {
|
||||
error.value = err?.message || "Failed to load admin payments";
|
||||
} finally {
|
||||
@@ -60,10 +98,15 @@ const resetCreateForm = () => {
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
statusOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const applyFilters = async () => {
|
||||
page.value = 1;
|
||||
appliedUserFilter.value = userFilter.value;
|
||||
await loadPayments();
|
||||
};
|
||||
|
||||
const openStatusDialog = (row: AdminPaymentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -104,7 +147,6 @@ const submitStatusUpdate = async () => {
|
||||
status: statusForm.status,
|
||||
});
|
||||
statusOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadPayments();
|
||||
} catch (err: any) {
|
||||
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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
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>
|
||||
</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 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>
|
||||
|
||||
<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">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">User</th>
|
||||
<th class="py-3 pr-4 font-medium">Amount</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Plan</th>
|
||||
<th class="py-3 pr-4 font-medium">Method</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
<thead class="bg-slate-50/90 text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold">Payment</th>
|
||||
<th class="px-4 py-3 font-semibold">User</th>
|
||||
<th class="px-4 py-3 font-semibold">Plan</th>
|
||||
<th class="px-4 py-3 font-semibold">Method</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Created</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">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 payments...</td>
|
||||
<tr v-if="loading" class="border-t border-slate-200">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-500">Loading payments...</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 payments found.</td>
|
||||
<tr v-else-if="rows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-500">No payments matched the current filters.</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">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.userEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.amount }} {{ row.currency }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.planName || row.planId || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.paymentMethod || '—' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<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="px-4 py-3">
|
||||
<button class="text-left" @click="selectedRow = row">
|
||||
<div class="font-medium text-slate-900">{{ formatMoney(row.amount, row.currency) }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
|
||||
</button>
|
||||
</td>
|
||||
<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">
|
||||
<AppButton size="sm" variant="secondary" @click="openStatusDialog(row)">Update status</AppButton>
|
||||
</div>
|
||||
@@ -165,6 +306,16 @@ onMounted(loadPayments);
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin payment" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
|
||||
@@ -6,7 +6,10 @@ import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "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 submitting = ref(false);
|
||||
@@ -17,7 +20,6 @@ const selectedRow = ref<AdminPlanRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const cycleOptions = ["monthly", "quarterly", "yearly"];
|
||||
|
||||
const createForm = reactive({
|
||||
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 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 () => {
|
||||
loading.value = true;
|
||||
@@ -57,6 +75,10 @@ const loadPlans = async () => {
|
||||
try {
|
||||
const response = await rpcClient.listAdminPlans();
|
||||
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) {
|
||||
error.value = err?.message || "Failed to load admin plans";
|
||||
} finally {
|
||||
@@ -79,7 +101,6 @@ const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
@@ -146,7 +167,6 @@ const submitEdit = async () => {
|
||||
isActive: editForm.isActive,
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadPlans();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update plan";
|
||||
@@ -177,55 +197,102 @@ onMounted(loadPlans);
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
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>
|
||||
</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 v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
<div class="mt-5 grid grid-cols-2 gap-3 text-sm text-slate-700">
|
||||
<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 v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Name</th>
|
||||
<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 class="mt-5 flex items-center justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click.stop="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click.stop="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
|
||||
@@ -3,23 +3,32 @@ import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.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";
|
||||
|
||||
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 submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
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 editOpen = ref(false);
|
||||
const roleOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const selectedRow = ref<AdminUserRow | null>(null);
|
||||
|
||||
const createForm = reactive({
|
||||
email: "",
|
||||
@@ -46,6 +55,27 @@ const roleForm = reactive({
|
||||
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 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 trimmed = value.trim();
|
||||
@@ -65,16 +95,30 @@ const closeDialogs = () => {
|
||||
editOpen.value = false;
|
||||
roleOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.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 () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
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 ?? [];
|
||||
total.value = response.total ?? rows.value.length;
|
||||
limit.value = response.limit ?? limit.value;
|
||||
page.value = response.page ?? page.value;
|
||||
syncSelectedRow();
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin users";
|
||||
} finally {
|
||||
@@ -82,6 +126,12 @@ const loadUsers = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = async () => {
|
||||
page.value = 1;
|
||||
appliedSearch.value = search.value;
|
||||
await loadUsers();
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -144,7 +194,6 @@ const submitEdit = async () => {
|
||||
planId: normalizeOptional(editForm.planId),
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update user";
|
||||
@@ -163,7 +212,6 @@ const submitRole = async () => {
|
||||
role: roleForm.role,
|
||||
});
|
||||
roleOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update role";
|
||||
@@ -180,6 +228,7 @@ const submitDelete = async () => {
|
||||
await rpcClient.deleteAdminUser({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
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>
|
||||
</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 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>
|
||||
|
||||
<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">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">Username</th>
|
||||
<th class="py-3 pr-4 font-medium">Email</th>
|
||||
<th class="py-3 pr-4 font-medium">Role</th>
|
||||
<th class="py-3 pr-4 font-medium">Plan</th>
|
||||
<th class="py-3 pr-4 font-medium">Videos</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
<thead class="bg-slate-50/90 text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold">User</th>
|
||||
<th class="px-4 py-3 font-semibold">Role</th>
|
||||
<th class="px-4 py-3 font-semibold">Plan</th>
|
||||
<th class="px-4 py-3 font-semibold">Videos</th>
|
||||
<th class="px-4 py-3 font-semibold">Created</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">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 users...</td>
|
||||
<tr v-if="loading" class="border-t border-slate-200">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-slate-500">Loading users...</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 users found.</td>
|
||||
<tr v-else-if="rows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="6" class="px-4 py-10 text-center text-slate-500">No users matched the current filters.</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">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.username || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.email }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.role || 'USER' }}</td>
|
||||
<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="py-3 text-right">
|
||||
<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="px-4 py-3">
|
||||
<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">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="ghost" @click="openRoleDialog(row)">Role</AppButton>
|
||||
@@ -242,9 +395,21 @@ onMounted(loadUsers);
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<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 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">
|
||||
@@ -280,7 +445,7 @@ onMounted(loadUsers);
|
||||
</template>
|
||||
</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 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">
|
||||
|
||||
@@ -3,21 +3,32 @@ import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.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";
|
||||
|
||||
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 submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminVideoRow[]>([]);
|
||||
const total = ref(0);
|
||||
const limit = ref(12);
|
||||
const page = ref(1);
|
||||
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 editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const statusOptions = ["UPLOADED", "PROCESSING", "READY", "FAILED"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
@@ -44,6 +55,27 @@ const editForm = reactive({
|
||||
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 trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
@@ -51,9 +83,6 @@ const normalizeOptional = (value: string) => {
|
||||
|
||||
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 = () => {
|
||||
createForm.userId = "";
|
||||
createForm.title = "";
|
||||
@@ -70,16 +99,31 @@ const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.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 () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
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 ?? [];
|
||||
total.value = response.total ?? rows.value.length;
|
||||
limit.value = response.limit ?? limit.value;
|
||||
page.value = response.page ?? page.value;
|
||||
syncSelectedRow();
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin videos";
|
||||
} 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) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
@@ -153,7 +204,6 @@ const submitEdit = async () => {
|
||||
adTemplateId: normalizeOptional(editForm.adTemplateId),
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update video";
|
||||
@@ -170,6 +220,7 @@ const submitDelete = async () => {
|
||||
await rpcClient.deleteAdminVideo({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
if (page.value > 1 && rows.value.length === 1) page.value -= 1;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
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>
|
||||
</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 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>
|
||||
|
||||
<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">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">Title</th>
|
||||
<th class="py-3 pr-4 font-medium">Owner</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Format</th>
|
||||
<th class="py-3 pr-4 font-medium">Size</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
<thead class="bg-slate-50/90 text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-semibold">Video</th>
|
||||
<th class="px-4 py-3 font-semibold">Owner</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold">Format</th>
|
||||
<th class="px-4 py-3 font-semibold">Size</th>
|
||||
<th class="px-4 py-3 font-semibold">Duration</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">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 videos...</td>
|
||||
<tr v-if="loading" class="border-t border-slate-200">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-500">Loading videos...</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 videos found.</td>
|
||||
<tr v-else-if="rows.length === 0" class="border-t border-slate-200">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-slate-500">No videos matched the current filters.</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">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.title }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.ownerEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.format || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.size ?? 0 }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<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="px-4 py-3">
|
||||
<button class="text-left" @click="selectedRow = row">
|
||||
<div class="font-medium text-slate-900">{{ row.title }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.id }}</div>
|
||||
</button>
|
||||
</td>
|
||||
<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">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
@@ -231,6 +406,16 @@ onMounted(loadVideos);
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin video" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
|
||||
@@ -4,15 +4,44 @@ import PageHeader from "@/components/dashboard/PageHeader.vue";
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
eyebrow?: string;
|
||||
badge?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
</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 />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { RedisClient } from "bun";
|
||||
import type { Hono } from "hono";
|
||||
import { contextStorage } from "hono/context-storage";
|
||||
import { cors } from "hono/cors";
|
||||
@@ -10,11 +9,18 @@ type AppFetch = (
|
||||
requestInit?: RequestInit
|
||||
) => 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" {
|
||||
interface ContextVariableMap {
|
||||
fetch: AppFetch;
|
||||
isMobile: boolean;
|
||||
redis: RedisClient;
|
||||
redis: RedisClientLike;
|
||||
jwtProvider: JwtProvider;
|
||||
jwtPayload: Record<string, unknown>;
|
||||
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) {
|
||||
app.use(
|
||||
@@ -37,7 +73,7 @@ export function setupMiddlewares(app: Hono) {
|
||||
}),
|
||||
contextStorage(),
|
||||
async (c, next) => {
|
||||
c.set("jwtProvider", JwtProvider.newJWTProvider("your-secret-key"));
|
||||
c.set("jwtProvider", JwtProvider.newJWTProvider(getJwtSecret()));
|
||||
await next();
|
||||
}
|
||||
);
|
||||
@@ -55,7 +91,9 @@ export function setupMiddlewares(app: Hono) {
|
||||
});
|
||||
app.use(async (c, next) => {
|
||||
try {
|
||||
return await redisClient.connect().then(() => c.set("redis", redisClient)).then(next)
|
||||
const redisClient = await getRedisClient();
|
||||
c.set("redis", redisClient);
|
||||
await next();
|
||||
} catch (e) {
|
||||
console.error("Failed to connect to Redis", e);
|
||||
return c.json({ error: "Redis unavailable" }, 500);
|
||||
|
||||
Reference in New Issue
Block a user