update grpc

This commit is contained in:
2026-03-12 09:33:28 +00:00
parent 5c0ca0e139
commit 57903b80b6
66 changed files with 24100 additions and 1562 deletions

View File

@@ -0,0 +1,334 @@
<script setup lang="ts">
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 AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminAdTemplateRow = any;
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 selectedRow = ref<AdminAdTemplateRow | null>(null);
const createOpen = ref(false);
const editOpen = ref(false);
const deleteOpen = ref(false);
const formatOptions = ["pre-roll", "mid-roll", "post-roll"];
const createForm = reactive({
userId: "",
name: "",
description: "",
vastTagUrl: "",
adFormat: "pre-roll",
duration: null as number | null,
isActive: true,
isDefault: false,
});
const editForm = reactive({
id: "",
userId: "",
name: "",
description: "",
vastTagUrl: "",
adFormat: "pre-roll",
duration: null as number | null,
isActive: true,
isDefault: false,
});
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 loadTemplates = async () => {
loading.value = true;
error.value = null;
try {
const response = await rpcClient.listAdminAdTemplates({ page: 1, limit: 20 });
rows.value = response.templates ?? [];
} catch (err: any) {
error.value = err?.message || "Failed to load admin ad templates";
} finally {
loading.value = false;
}
};
const resetCreateForm = () => {
createForm.userId = "";
createForm.name = "";
createForm.description = "";
createForm.vastTagUrl = "";
createForm.adFormat = "pre-roll";
createForm.duration = null;
createForm.isActive = true;
createForm.isDefault = false;
};
const closeDialogs = () => {
createOpen.value = false;
editOpen.value = false;
deleteOpen.value = false;
selectedRow.value = null;
actionError.value = null;
};
const openEditDialog = (row: AdminAdTemplateRow) => {
selectedRow.value = row;
actionError.value = null;
editForm.id = row.id || "";
editForm.userId = row.userId || "";
editForm.name = row.name || "";
editForm.description = row.description || "";
editForm.vastTagUrl = row.vastTagUrl || "";
editForm.adFormat = row.adFormat || "pre-roll";
editForm.duration = row.duration ?? null;
editForm.isActive = !!row.isActive;
editForm.isDefault = !!row.isDefault;
editOpen.value = true;
};
const openDeleteDialog = (row: AdminAdTemplateRow) => {
selectedRow.value = row;
actionError.value = null;
deleteOpen.value = true;
};
const submitCreate = async () => {
if (!canCreate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.createAdminAdTemplate({
userId: createForm.userId.trim(),
name: createForm.name.trim(),
description: createForm.description.trim() || undefined,
vastTagUrl: createForm.vastTagUrl.trim(),
adFormat: createForm.adFormat,
duration: createForm.duration == null ? undefined : createForm.duration,
isActive: createForm.isActive,
isDefault: createForm.isDefault,
});
resetCreateForm();
createOpen.value = false;
await loadTemplates();
} catch (err: any) {
actionError.value = err?.message || "Failed to create ad template";
} finally {
submitting.value = false;
}
};
const submitEdit = async () => {
if (!canUpdate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.updateAdminAdTemplate({
id: editForm.id,
userId: editForm.userId.trim(),
name: editForm.name.trim(),
description: editForm.description.trim() || undefined,
vastTagUrl: editForm.vastTagUrl.trim(),
adFormat: editForm.adFormat,
duration: editForm.duration == null ? undefined : editForm.duration,
isActive: editForm.isActive,
isDefault: editForm.isDefault,
});
editOpen.value = false;
selectedRow.value = null;
await loadTemplates();
} catch (err: any) {
actionError.value = err?.message || "Failed to update ad template";
} finally {
submitting.value = false;
}
};
const submitDelete = async () => {
if (!selectedRow.value?.id) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.deleteAdminAdTemplate({ id: selectedRow.value.id });
deleteOpen.value = false;
selectedRow.value = null;
await loadTemplates();
} catch (err: any) {
actionError.value = err?.message || "Failed to delete ad template";
} finally {
submitting.value = false;
}
};
onMounted(loadTemplates);
</script>
<template>
<AdminSectionShell
title="Admin Ad Templates"
description="Cross-user ad template management over admin gRPC service."
>
<div class="mb-4 flex justify-end">
<AppButton size="sm" @click="actionError = null; createOpen = true">Create template</AppButton>
</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-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">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>
</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>
<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>
<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>
</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">
<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>
</td>
</tr>
</tbody>
</table>
</div>
</AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create ad template" 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">
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
<AppInput v-model="createForm.userId" placeholder="user-id" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Name</label>
<AppInput v-model="createForm.name" placeholder="Preroll template" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="createForm.description" rows="3" 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="Optional" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">VAST URL</label>
<AppInput v-model="createForm.vastTagUrl" placeholder="https://..." />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad format</label>
<select v-model="createForm.adFormat" 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="format in formatOptions" :key="format" :value="format">{{ format }}</option>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label>
<AppInput v-model="createForm.duration" type="number" min="0" placeholder="Optional" />
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
Active
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input v-model="createForm.isDefault" type="checkbox" class="h-4 w-4" />
Default
</label>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="editOpen" title="Edit ad template" 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">
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
<AppInput v-model="editForm.userId" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Name</label>
<AppInput v-model="editForm.name" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="editForm.description" rows="3" 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" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">VAST URL</label>
<AppInput v-model="editForm.vastTagUrl" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad format</label>
<select v-model="editForm.adFormat" 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="format in formatOptions" :key="format" :value="format">{{ format }}</option>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label>
<AppInput v-model="editForm.duration" type="number" min="0" placeholder="Optional" />
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
Active
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input v-model="editForm.isDefault" type="checkbox" class="h-4 w-4" />
Default
</label>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="deleteOpen" title="Delete ad template" maxWidthClass="max-w-md" @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>
<p class="text-sm text-gray-700">
Delete ad template <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
</div>
</template>
</AppDialog>
</template>

191
src/routes/admin/Agents.vue Normal file
View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
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 AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminAgentRow = any;
const loading = ref(true);
const submitting = ref(false);
const error = ref<string | null>(null);
const actionError = ref<string | null>(null);
const rows = ref<AdminAgentRow[]>([]);
const selectedRow = ref<AdminAgentRow | null>(null);
const restartOpen = ref(false);
const updateOpen = ref(false);
const loadAgents = async () => {
loading.value = true;
error.value = null;
try {
const response = await rpcClient.listAdminAgents();
rows.value = response.agents ?? [];
} catch (err: any) {
error.value = err?.message || "Failed to load admin agents";
} finally {
loading.value = false;
}
};
const closeDialogs = () => {
restartOpen.value = false;
updateOpen.value = false;
selectedRow.value = null;
actionError.value = null;
};
const openRestartDialog = (row: AdminAgentRow) => {
selectedRow.value = row;
actionError.value = null;
restartOpen.value = true;
};
const openUpdateDialog = (row: AdminAgentRow) => {
selectedRow.value = row;
actionError.value = null;
updateOpen.value = true;
};
const submitRestart = async () => {
if (!selectedRow.value?.id) return;
submitting.value = true;
actionError.value = null;
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";
} finally {
submitting.value = false;
}
};
const submitUpdate = async () => {
if (!selectedRow.value?.id) return;
submitting.value = true;
actionError.value = null;
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";
} finally {
submitting.value = false;
}
};
useAdminRuntimeMqtt(({ topic, payload }) => {
if (topic !== "picpic/events" || payload?.type !== "agent_update") return;
const update = payload.payload;
if (!update?.id) return;
const row = rows.value.find((item) => item.id === update.id);
if (row) {
Object.assign(row, {
...row,
...update,
lastHeartbeat: update.last_heartbeat || row.lastHeartbeat,
createdAt: update.created_at || row.createdAt,
updatedAt: update.updated_at || row.updatedAt,
});
} else {
loadAgents();
}
});
onMounted(loadAgents);
</script>
<template>
<AdminSectionShell
title="Admin Agents"
description="Connected render workers and command controls over admin gRPC service."
>
<div class="mb-4 flex justify-end">
<AppButton size="sm" variant="secondary" @click="loadAgents">Refresh agents</AppButton>
</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-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">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>
</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>
<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>
<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>
</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">
<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>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</AdminSectionShell>
<AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @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>
<p class="text-sm text-gray-700">
Send restart command to <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitRestart">Restart</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="updateOpen" title="Update agent" maxWidthClass="max-w-md" @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>
<p class="text-sm text-gray-700">
Send update command to <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
<AppButton size="sm" :loading="submitting" @click="submitUpdate">Update</AppButton>
</div>
</template>
</AppDialog>
</template>

362
src/routes/admin/Jobs.vue Normal file
View File

@@ -0,0 +1,362 @@
<script setup lang="ts">
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 { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import { computed, onMounted, reactive, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminJobRow = any;
const loading = ref(true);
const submitting = ref(false);
const error = ref<string | null>(null);
const actionError = ref<string | null>(null);
const rows = ref<AdminJobRow[]>([]);
const selectedRow = ref<AdminJobRow | null>(null);
const selectedLogs = ref("");
const createOpen = ref(false);
const logsOpen = ref(false);
const cancelOpen = ref(false);
const retryOpen = ref(false);
const activeAgentFilter = ref("");
const createForm = reactive({
command: "",
image: "alpine",
userId: "",
name: "",
timeLimit: 0,
priority: 0,
envText: "",
});
const parseEnvText = (value: string) =>
value
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.reduce<Record<string, string>>((acc, line) => {
const separatorIndex = line.indexOf("=");
if (separatorIndex === -1) return acc;
const key = line.slice(0, separatorIndex).trim();
const val = line.slice(separatorIndex + 1).trim();
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 resetCreateForm = () => {
createForm.command = "";
createForm.image = "alpine";
createForm.userId = "";
createForm.name = "";
createForm.timeLimit = 0;
createForm.priority = 0;
createForm.envText = "";
};
const closeDialogs = () => {
createOpen.value = false;
logsOpen.value = false;
cancelOpen.value = false;
retryOpen.value = false;
selectedRow.value = null;
selectedLogs.value = "";
actionError.value = null;
};
const loadJobs = async () => {
loading.value = true;
error.value = null;
try {
const response = await rpcClient.listAdminJobs({
offset: 0,
limit: 50,
agentId: activeAgentFilter.value.trim() || undefined,
});
rows.value = response.jobs ?? [];
} catch (err: any) {
error.value = err?.message || "Failed to load admin jobs";
} finally {
loading.value = false;
}
};
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.";
} catch (err: any) {
selectedLogs.value = "";
actionError.value = err?.message || "Failed to load job logs";
}
};
const openCancelDialog = (row: AdminJobRow) => {
selectedRow.value = row;
actionError.value = null;
cancelOpen.value = true;
};
const openRetryDialog = (row: AdminJobRow) => {
selectedRow.value = row;
actionError.value = null;
retryOpen.value = true;
};
const submitCreate = async () => {
if (!canCreate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.createAdminJob({
command: createForm.command.trim(),
image: createForm.image.trim() || undefined,
userId: createForm.userId.trim() || undefined,
name: createForm.name.trim() || undefined,
timeLimit: createForm.timeLimit > 0 ? createForm.timeLimit : undefined,
priority: createForm.priority,
env: hasEnv.value ? parseEnvText(createForm.envText) : undefined,
});
resetCreateForm();
createOpen.value = false;
await loadJobs();
} catch (err: any) {
actionError.value = err?.message || "Failed to create job";
} finally {
submitting.value = false;
}
};
const submitCancel = async () => {
if (!selectedRow.value?.id) return;
submitting.value = true;
actionError.value = null;
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";
} finally {
submitting.value = false;
}
};
const submitRetry = async () => {
if (!selectedRow.value?.id) return;
submitting.value = true;
actionError.value = null;
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";
} finally {
submitting.value = false;
}
};
useAdminRuntimeMqtt(({ topic, payload }) => {
if (topic.startsWith("picpic/job/") && payload?.type === "job_update") {
const update = payload.payload;
const jobId = update?.job_id;
const status = update?.status;
if (!jobId || !status) return;
const row = rows.value.find((item) => item.id === jobId);
if (row) {
row.status = status;
row.updatedAt = new Date().toISOString();
} else {
loadJobs();
}
}
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;
}
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}`;
}
}
if (topic === "picpic/events" && payload?.type === "resource_update") {
const update = payload.payload;
if (!update?.agent_id) return;
rows.value.forEach((row) => {
if (row.agentId === update.agent_id) {
row.updatedAt = new Date().toISOString();
}
});
}
});
onMounted(loadJobs);
</script>
<template>
<AdminSectionShell
title="Admin Jobs"
description="Runtime job queue over admin gRPC service."
>
<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>
<AppButton size="sm" @click="actionError = null; createOpen = true">Create job</AppButton>
</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-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">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>
</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>
<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>
<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>
</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">
<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>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create job" 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">
<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 ..." />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Image</label>
<AppInput v-model="createForm.image" placeholder="alpine" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
<AppInput v-model="createForm.userId" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Display name</label>
<AppInput v-model="createForm.name" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Priority</label>
<AppInput v-model="createForm.priority" type="number" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Time limit</label>
<AppInput v-model="createForm.timeLimit" type="number" min="0" placeholder="Seconds" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Environment</label>
<textarea v-model="createForm.envText" rows="5" 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="KEY=value per line" />
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="logsOpen" title="Job logs" maxWidthClass="max-w-3xl" @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">
{{ selectedLogs || 'No logs available.' }}
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" @click="closeDialogs">Close</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="cancelOpen" title="Cancel job" maxWidthClass="max-w-md" @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>
<p class="text-sm text-gray-700">
Cancel job <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitCancel">Cancel job</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="retryOpen" title="Retry job" maxWidthClass="max-w-md" @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>
<p class="text-sm text-gray-700">
Retry job <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
<AppButton size="sm" :loading="submitting" @click="submitRetry">Retry</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

59
src/routes/admin/Logs.vue Normal file
View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
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 AdminSectionShell from "./components/AdminSectionShell.vue";
const loading = ref(false);
const error = ref<string | null>(null);
const jobId = ref("");
const logs = ref("Enter a job ID and load logs.");
const loadLogs = async () => {
if (!jobId.value.trim()) return;
loading.value = true;
error.value = null;
try {
const response = await rpcClient.getAdminJobLogs({ id: jobId.value.trim() });
logs.value = response.logs || "No logs available.";
} catch (err: any) {
error.value = err?.message || "Failed to load logs";
logs.value = "";
} finally {
loading.value = false;
}
};
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") {
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}`;
}
});
</script>
<template>
<AdminSectionShell
title="Admin Logs"
description="Fetch persisted logs by job ID over admin gRPC service."
>
<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-..." />
</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">
{{ 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>
</AdminSectionShell>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient";
import StatsCard from "@/components/dashboard/StatsCard.vue";
import { computed, onMounted, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue";
const loading = ref(true);
const error = ref<string | null>(null);
const dashboard = ref<any | 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 },
];
});
onMounted(async () => {
try {
dashboard.value = await rpcClient.getAdminDashboard();
} catch (err: any) {
error.value = err?.message || "Failed to load admin dashboard";
} finally {
loading.value = false;
}
});
</script>
<template>
<AdminSectionShell
title="Admin Overview"
description="System-wide metrics from backend gRPC admin service."
>
<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-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>
</AdminSectionShell>
</template>

View File

@@ -0,0 +1,223 @@
<script setup lang="ts">
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 AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminPaymentRow = any;
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 selectedRow = ref<AdminPaymentRow | null>(null);
const createOpen = ref(false);
const statusOpen = ref(false);
const paymentMethodOptions = ["TOPUP", "WALLET"];
const statusOptions = ["PENDING", "SUCCESS", "FAILED", "CANCELLED"];
const createForm = reactive({
userId: "",
planId: "",
termMonths: 1,
paymentMethod: "TOPUP",
topupAmount: null as number | null,
});
const statusForm = reactive({
id: "",
status: "PENDING",
});
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 loadPayments = async () => {
loading.value = true;
error.value = null;
try {
const response = await rpcClient.listAdminPayments({ page: 1, limit: 20 });
rows.value = response.payments ?? [];
} catch (err: any) {
error.value = err?.message || "Failed to load admin payments";
} finally {
loading.value = false;
}
};
const resetCreateForm = () => {
createForm.userId = "";
createForm.planId = "";
createForm.termMonths = 1;
createForm.paymentMethod = "TOPUP";
createForm.topupAmount = null;
};
const closeDialogs = () => {
createOpen.value = false;
statusOpen.value = false;
selectedRow.value = null;
actionError.value = null;
};
const openStatusDialog = (row: AdminPaymentRow) => {
selectedRow.value = row;
actionError.value = null;
statusForm.id = row.id || "";
statusForm.status = row.status || "PENDING";
statusOpen.value = true;
};
const submitCreate = async () => {
if (!canCreate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.createAdminPayment({
userId: createForm.userId.trim(),
planId: createForm.planId.trim(),
termMonths: createForm.termMonths,
paymentMethod: createForm.paymentMethod,
topupAmount: createForm.topupAmount == null ? undefined : createForm.topupAmount,
});
resetCreateForm();
createOpen.value = false;
await loadPayments();
} catch (err: any) {
actionError.value = err?.message || "Failed to create payment";
} finally {
submitting.value = false;
}
};
const submitStatusUpdate = async () => {
if (!canUpdateStatus.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.updateAdminPayment({
id: statusForm.id,
status: statusForm.status,
});
statusOpen.value = false;
selectedRow.value = null;
await loadPayments();
} catch (err: any) {
actionError.value = err?.message || "Failed to update payment";
} finally {
submitting.value = false;
}
};
onMounted(loadPayments);
</script>
<template>
<AdminSectionShell
title="Admin Payments"
description="Payment history from admin gRPC service."
>
<div class="mb-4 flex justify-end">
<AppButton size="sm" @click="actionError = null; createOpen = true">Create payment</AppButton>
</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-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">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>
</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>
<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>
<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">
<div class="flex justify-end gap-2">
<AppButton size="sm" variant="secondary" @click="openStatusDialog(row)">Update status</AppButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create admin payment" maxWidthClass="max-w-lg" @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">
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">User ID</label>
<AppInput v-model="createForm.userId" placeholder="user-id" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Plan ID</label>
<AppInput v-model="createForm.planId" placeholder="plan-id" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Term months</label>
<AppInput v-model="createForm.termMonths" type="number" min="1" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Payment method</label>
<select v-model="createForm.paymentMethod" 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="method in paymentMethodOptions" :key="method" :value="method">{{ method }}</option>
</select>
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Topup amount</label>
<AppInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" />
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="statusOpen" title="Update payment status" maxWidthClass="max-w-md" @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="space-y-2">
<label class="text-sm font-medium text-gray-700">Status</label>
<select v-model="statusForm.status" 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 statusOptions" :key="status" :value="status">{{ status }}</option>
</select>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canUpdateStatus" @click="submitStatusUpdate">Save</AppButton>
</div>
</template>
</AppDialog>
</template>

342
src/routes/admin/Plans.vue Normal file
View File

@@ -0,0 +1,342 @@
<script setup lang="ts">
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 AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminPlanRow = any;
const loading = ref(true);
const submitting = ref(false);
const error = ref<string | null>(null);
const actionError = ref<string | null>(null);
const rows = ref<AdminPlanRow[]>([]);
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: "",
description: "",
featuresText: "",
price: 0,
cycle: "monthly",
storageLimit: 1,
uploadLimit: 1,
isActive: true,
});
const editForm = reactive({
id: "",
name: "",
description: "",
featuresText: "",
price: 0,
cycle: "monthly",
storageLimit: 1,
uploadLimit: 1,
isActive: true,
});
const parseFeatures = (value: string) =>
value
.split("\n")
.map((item) => item.trim())
.filter(Boolean);
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 loadPlans = async () => {
loading.value = true;
error.value = null;
try {
const response = await rpcClient.listAdminPlans();
rows.value = response.plans ?? [];
} catch (err: any) {
error.value = err?.message || "Failed to load admin plans";
} finally {
loading.value = false;
}
};
const resetCreateForm = () => {
createForm.name = "";
createForm.description = "";
createForm.featuresText = "";
createForm.price = 0;
createForm.cycle = "monthly";
createForm.storageLimit = 1;
createForm.uploadLimit = 1;
createForm.isActive = true;
};
const closeDialogs = () => {
createOpen.value = false;
editOpen.value = false;
deleteOpen.value = false;
selectedRow.value = null;
actionError.value = null;
};
const openEditDialog = (row: AdminPlanRow) => {
selectedRow.value = row;
actionError.value = null;
editForm.id = row.id || "";
editForm.name = row.name || "";
editForm.description = row.description || "";
editForm.featuresText = (row.features ?? []).join("\n");
editForm.price = row.price ?? 0;
editForm.cycle = row.cycle || "monthly";
editForm.storageLimit = row.storageLimit ?? 1;
editForm.uploadLimit = row.uploadLimit ?? 1;
editForm.isActive = !!row.isActive;
editOpen.value = true;
};
const openDeleteDialog = (row: AdminPlanRow) => {
selectedRow.value = row;
actionError.value = null;
deleteOpen.value = true;
};
const submitCreate = async () => {
if (!canCreate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.createAdminPlan({
name: createForm.name.trim(),
description: createForm.description.trim() || undefined,
features: parseFeatures(createForm.featuresText),
price: createForm.price,
cycle: createForm.cycle,
storageLimit: createForm.storageLimit,
uploadLimit: createForm.uploadLimit,
isActive: createForm.isActive,
});
resetCreateForm();
createOpen.value = false;
await loadPlans();
} catch (err: any) {
actionError.value = err?.message || "Failed to create plan";
} finally {
submitting.value = false;
}
};
const submitEdit = async () => {
if (!canUpdate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.updateAdminPlan({
id: editForm.id,
name: editForm.name.trim(),
description: editForm.description.trim() || undefined,
features: parseFeatures(editForm.featuresText),
price: editForm.price,
cycle: editForm.cycle,
storageLimit: editForm.storageLimit,
uploadLimit: editForm.uploadLimit,
isActive: editForm.isActive,
});
editOpen.value = false;
selectedRow.value = null;
await loadPlans();
} catch (err: any) {
actionError.value = err?.message || "Failed to update plan";
} finally {
submitting.value = false;
}
};
const submitDelete = async () => {
if (!selectedRow.value?.id) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.deleteAdminPlan({ id: selectedRow.value.id });
deleteOpen.value = false;
selectedRow.value = null;
await loadPlans();
} catch (err: any) {
actionError.value = err?.message || "Failed to delete plan";
} finally {
submitting.value = false;
}
};
onMounted(loadPlans);
</script>
<template>
<AdminSectionShell
title="Admin Plans"
description="Subscription plans managed via admin gRPC service."
>
<div class="mb-4 flex justify-end">
<AppButton size="sm" @click="actionError = null; createOpen = true">Create plan</AppButton>
</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-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>
</td>
</tr>
</tbody>
</table>
</div>
</AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create admin plan" 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">
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Name</label>
<AppInput v-model="createForm.name" placeholder="Starter" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="createForm.description" rows="3" 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="Optional" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Features</label>
<textarea v-model="createForm.featuresText" 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="One feature per line" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Price</label>
<AppInput v-model="createForm.price" type="number" min="0" step="0.01" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Cycle</label>
<select v-model="createForm.cycle" 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="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Storage limit</label>
<AppInput v-model="createForm.storageLimit" type="number" min="1" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Upload limit</label>
<AppInput v-model="createForm.uploadLimit" type="number" min="1" />
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
Active
</label>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="editOpen" title="Edit plan" 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">
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Name</label>
<AppInput v-model="editForm.name" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="editForm.description" rows="3" 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" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Features</label>
<textarea v-model="editForm.featuresText" 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" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Price</label>
<AppInput v-model="editForm.price" type="number" min="0" step="0.01" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Cycle</label>
<select v-model="editForm.cycle" 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="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Storage limit</label>
<AppInput v-model="editForm.storageLimit" type="number" min="1" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Upload limit</label>
<AppInput v-model="editForm.uploadLimit" type="number" min="1" />
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
Active
</label>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="deleteOpen" title="Delete plan" maxWidthClass="max-w-md" @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>
<p class="text-sm text-gray-700">
Delete or deactivate plan <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
</div>
</template>
</AppDialog>
</template>

351
src/routes/admin/Users.vue Normal file
View File

@@ -0,0 +1,351 @@
<script setup lang="ts">
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 AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminUserRow = any;
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 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: "",
username: "",
password: "",
role: "USER",
planId: "",
});
const editForm = reactive({
id: "",
email: "",
username: "",
password: "",
role: "USER",
planId: "",
});
const roleForm = reactive({
id: "",
role: "USER",
});
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 normalizeOptional = (value: string) => {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
};
const resetCreateForm = () => {
createForm.email = "";
createForm.username = "";
createForm.password = "";
createForm.role = "USER";
createForm.planId = "";
};
const closeDialogs = () => {
createOpen.value = false;
editOpen.value = false;
roleOpen.value = false;
deleteOpen.value = false;
selectedRow.value = null;
actionError.value = null;
};
const loadUsers = async () => {
loading.value = true;
error.value = null;
try {
const response = await rpcClient.listAdminUsers({ page: 1, limit: 20 });
rows.value = response.users ?? [];
} catch (err: any) {
error.value = err?.message || "Failed to load admin users";
} finally {
loading.value = false;
}
};
const openEditDialog = (row: AdminUserRow) => {
selectedRow.value = row;
actionError.value = null;
editForm.id = row.id || "";
editForm.email = row.email || "";
editForm.username = row.username || "";
editForm.password = "";
editForm.role = row.role || "USER";
editForm.planId = row.planId || "";
editOpen.value = true;
};
const openRoleDialog = (row: AdminUserRow) => {
selectedRow.value = row;
actionError.value = null;
roleForm.id = row.id || "";
roleForm.role = row.role || "USER";
roleOpen.value = true;
};
const openDeleteDialog = (row: AdminUserRow) => {
selectedRow.value = row;
actionError.value = null;
deleteOpen.value = true;
};
const submitCreate = async () => {
if (!canCreate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.createAdminUser({
email: createForm.email.trim(),
username: normalizeOptional(createForm.username),
password: createForm.password,
role: createForm.role,
planId: normalizeOptional(createForm.planId),
});
resetCreateForm();
createOpen.value = false;
await loadUsers();
} catch (err: any) {
actionError.value = err?.message || "Failed to create user";
} finally {
submitting.value = false;
}
};
const submitEdit = async () => {
if (!canUpdate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.updateAdminUser({
id: editForm.id,
email: editForm.email.trim(),
username: normalizeOptional(editForm.username),
password: normalizeOptional(editForm.password),
role: editForm.role,
planId: normalizeOptional(editForm.planId),
});
editOpen.value = false;
selectedRow.value = null;
await loadUsers();
} catch (err: any) {
actionError.value = err?.message || "Failed to update user";
} finally {
submitting.value = false;
}
};
const submitRole = async () => {
if (!canUpdateRole.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.updateAdminUserRole({
id: roleForm.id,
role: roleForm.role,
});
roleOpen.value = false;
selectedRow.value = null;
await loadUsers();
} catch (err: any) {
actionError.value = err?.message || "Failed to update role";
} finally {
submitting.value = false;
}
};
const submitDelete = async () => {
if (!selectedRow.value?.id) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.deleteAdminUser({ id: selectedRow.value.id });
deleteOpen.value = false;
selectedRow.value = null;
await loadUsers();
} catch (err: any) {
actionError.value = err?.message || "Failed to delete user";
} finally {
submitting.value = false;
}
};
onMounted(loadUsers);
</script>
<template>
<AdminSectionShell
title="Admin Users"
description="User management data from admin gRPC service."
>
<div class="mb-4 flex justify-end">
<AppButton size="sm" @click="actionError = null; createOpen = true">Create user</AppButton>
</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-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">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>
</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>
<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>
<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">
<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>
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create admin user" maxWidthClass="max-w-lg" @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">
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Email</label>
<AppInput v-model="createForm.email" placeholder="user@example.com" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Username</label>
<AppInput v-model="createForm.username" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Role</label>
<select v-model="createForm.role" 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 roleOptions" :key="role" :value="role">{{ role }}</option>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Password</label>
<AppInput v-model="createForm.password" type="password" placeholder="Minimum 6 characters" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Plan ID</label>
<AppInput v-model="createForm.planId" placeholder="Optional" />
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="editOpen" title="Edit user" maxWidthClass="max-w-lg" @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">
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Email</label>
<AppInput v-model="editForm.email" placeholder="user@example.com" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Username</label>
<AppInput v-model="editForm.username" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Role</label>
<select v-model="editForm.role" 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 roleOptions" :key="role" :value="role">{{ role }}</option>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Reset password</label>
<AppInput v-model="editForm.password" type="password" placeholder="Leave blank to keep current" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Plan ID</label>
<AppInput v-model="editForm.planId" placeholder="Optional" />
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="roleOpen" title="Update user role" maxWidthClass="max-w-md" @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="space-y-2">
<label class="text-sm font-medium text-gray-700">Role</label>
<select v-model="roleForm.role" 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 roleOptions" :key="role" :value="role">{{ role }}</option>
</select>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canUpdateRole" @click="submitRole">Update role</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="deleteOpen" title="Delete user" maxWidthClass="max-w-md" @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>
<p class="text-sm text-gray-700">
Delete <span class="font-medium">{{ selectedRow?.email || selectedRow?.id }}</span> and related data.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
</div>
</template>
</AppDialog>
</template>

354
src/routes/admin/Videos.vue Normal file
View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
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 AdminSectionShell from "./components/AdminSectionShell.vue";
type AdminVideoRow = any;
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 selectedRow = ref<AdminVideoRow | null>(null);
const createOpen = ref(false);
const editOpen = ref(false);
const deleteOpen = ref(false);
const statusOptions = ["UPLOADED", "PROCESSING", "READY", "FAILED"];
const createForm = reactive({
userId: "",
title: "",
description: "",
url: "",
size: null as number | null,
duration: null as number | null,
format: "",
status: "READY",
adTemplateId: "",
});
const editForm = reactive({
id: "",
userId: "",
title: "",
description: "",
url: "",
size: null as number | null,
duration: null as number | null,
format: "",
status: "READY",
adTemplateId: "",
});
const normalizeOptional = (value: string) => {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
};
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 = "";
createForm.description = "";
createForm.url = "";
createForm.size = null;
createForm.duration = null;
createForm.format = "";
createForm.status = "READY";
createForm.adTemplateId = "";
};
const closeDialogs = () => {
createOpen.value = false;
editOpen.value = false;
deleteOpen.value = false;
selectedRow.value = null;
actionError.value = null;
};
const loadVideos = async () => {
loading.value = true;
error.value = null;
try {
const response = await rpcClient.listAdminVideos({ page: 1, limit: 20 });
rows.value = response.videos ?? [];
} catch (err: any) {
error.value = err?.message || "Failed to load admin videos";
} finally {
loading.value = false;
}
};
const openEditDialog = (row: AdminVideoRow) => {
selectedRow.value = row;
actionError.value = null;
editForm.id = row.id || "";
editForm.userId = row.userId || "";
editForm.title = row.title || "";
editForm.description = row.description || "";
editForm.url = row.url || "";
editForm.size = row.size ?? null;
editForm.duration = row.duration ?? null;
editForm.format = row.format || "";
editForm.status = row.status || "READY";
editForm.adTemplateId = row.adTemplateId || "";
editOpen.value = true;
};
const openDeleteDialog = (row: AdminVideoRow) => {
selectedRow.value = row;
actionError.value = null;
deleteOpen.value = true;
};
const submitCreate = async () => {
if (!canCreate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.createAdminVideo({
userId: createForm.userId.trim(),
title: createForm.title.trim(),
description: normalizeOptional(createForm.description),
url: createForm.url.trim(),
size: normalizeNumber(createForm.size),
duration: normalizeNumber(createForm.duration),
format: normalizeOptional(createForm.format),
status: createForm.status,
adTemplateId: normalizeOptional(createForm.adTemplateId),
});
resetCreateForm();
createOpen.value = false;
await loadVideos();
} catch (err: any) {
actionError.value = err?.message || "Failed to create video";
} finally {
submitting.value = false;
}
};
const submitEdit = async () => {
if (!canUpdate.value) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.updateAdminVideo({
id: editForm.id,
userId: editForm.userId.trim(),
title: editForm.title.trim(),
description: normalizeOptional(editForm.description),
url: editForm.url.trim(),
size: normalizeNumber(editForm.size),
duration: normalizeNumber(editForm.duration),
format: normalizeOptional(editForm.format),
status: editForm.status,
adTemplateId: normalizeOptional(editForm.adTemplateId),
});
editOpen.value = false;
selectedRow.value = null;
await loadVideos();
} catch (err: any) {
actionError.value = err?.message || "Failed to update video";
} finally {
submitting.value = false;
}
};
const submitDelete = async () => {
if (!selectedRow.value?.id) return;
submitting.value = true;
actionError.value = null;
try {
await rpcClient.deleteAdminVideo({ id: selectedRow.value.id });
deleteOpen.value = false;
selectedRow.value = null;
await loadVideos();
} catch (err: any) {
actionError.value = err?.message || "Failed to delete video";
} finally {
submitting.value = false;
}
};
onMounted(loadVideos);
</script>
<template>
<AdminSectionShell
title="Admin Videos"
description="Cross-user video list from admin gRPC service."
>
<div class="mb-4 flex justify-end">
<AppButton size="sm" @click="actionError = null; createOpen = true">Create video</AppButton>
</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-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">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>
</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>
<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>
<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">
<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>
</td>
</tr>
</tbody>
</table>
</div>
</AdminSectionShell>
<AppDialog v-model:visible="createOpen" title="Create admin video" 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">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
<AppInput v-model="createForm.userId" placeholder="user-id" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Status</label>
<select v-model="createForm.status" 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 statusOptions" :key="status" :value="status">{{ status }}</option>
</select>
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Title</label>
<AppInput v-model="createForm.title" placeholder="Video title" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Video URL</label>
<AppInput v-model="createForm.url" placeholder="https://..." />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="createForm.description" rows="3" 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="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Format</label>
<AppInput v-model="createForm.format" placeholder="mp4" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
<AppInput v-model="createForm.adTemplateId" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Size</label>
<AppInput v-model="createForm.size" type="number" placeholder="0" min="0" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label>
<AppInput v-model="createForm.duration" type="number" placeholder="0" min="0" />
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="editOpen" title="Edit video" 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">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
<AppInput v-model="editForm.userId" placeholder="user-id" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Status</label>
<select v-model="editForm.status" 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 statusOptions" :key="status" :value="status">{{ status }}</option>
</select>
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Title</label>
<AppInput v-model="editForm.title" placeholder="Video title" />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Video URL</label>
<AppInput v-model="editForm.url" placeholder="https://..." />
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-medium text-gray-700">Description</label>
<textarea v-model="editForm.description" rows="3" 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="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Format</label>
<AppInput v-model="editForm.format" placeholder="mp4" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
<AppInput v-model="editForm.adTemplateId" placeholder="Optional" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Size</label>
<AppInput v-model="editForm.size" type="number" placeholder="0" min="0" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Duration</label>
<AppInput v-model="editForm.duration" type="number" placeholder="0" min="0" />
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
</div>
</template>
</AppDialog>
<AppDialog v-model:visible="deleteOpen" title="Delete video" maxWidthClass="max-w-md" @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>
<p class="text-sm text-gray-700">
Delete video <span class="font-medium">{{ selectedRow?.title || selectedRow?.id }}</span>.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
defineProps<{
columns: string[];
rows?: number;
}>();
</script>
<template>
<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 v-for="column in columns" :key="column" class="py-3 pr-4 font-medium">
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="i in rows ?? 5" :key="i" class="border-b border-gray-100">
<td v-for="column in columns" :key="column" class="py-3 pr-4 text-gray-700">
</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import PageHeader from "@/components/dashboard/PageHeader.vue";
defineProps<{
title: string;
description: string;
}>();
</script>
<template>
<section class="space-y-4">
<PageHeader :title="title" :description="description" />
<div class="rounded-2xl border border-gray-200 bg-white p-6">
<slot />
</div>
</section>
</template>

View File

@@ -28,7 +28,7 @@
</template>
<script setup lang="ts">
import { client } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import { useAppToast } from '@/composables/useAppToast';
import { reactive } from 'vue';
import { useTranslation } from 'i18next-vue';
@@ -59,7 +59,7 @@ const onFormSubmit = () => {
return;
}
client.auth.forgotPasswordCreate({ email: form.email })
rpcClient.forgotPassword({ email: form.email })
.then(() => {
toast.add({
severity: 'success',

View File

@@ -20,11 +20,9 @@ const status = computed(() => String(route.query.status ?? 'error'));
const reason = computed(() => String(route.query.reason ?? 'google_login_failed'));
const reasonMessages: Record<string, string> = {
missing_state: 'Google login session is invalid. Please try again.',
invalid_state: 'Google login session has expired. Please try again.',
missing_code: 'Google did not return an authorization code.',
access_denied: 'Google login was cancelled.',
exchange_failed: 'Failed to sign in with Google.',
exchange_failed: 'Failed to verify your Google sign-in. Please try again.',
userinfo_failed: 'Failed to load your Google account information.',
userinfo_parse_failed: 'Failed to read your Google account information.',
missing_email: 'Your Google account did not provide an email address.',

View File

@@ -10,7 +10,7 @@ import {
} from "vue-router";
type RouteData = RouteRecordRaw & {
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean; requiresAdmin?: boolean };
children?: RouteData[];
};
const routes: RouteData[] = [
@@ -217,6 +217,23 @@ const routes: RouteData[] = [
},
],
},
{
path: "admin",
component: () => import("./admin/Layout.vue"),
meta: { requiresAdmin: true },
redirect: { name: "admin-overview" },
children: [
{ path: "overview", name: "admin-overview", component: () => import("./admin/Overview.vue") },
{ path: "users", name: "admin-users", component: () => import("./admin/Users.vue") },
{ path: "videos", name: "admin-videos", component: () => import("./admin/Videos.vue") },
{ path: "payments", name: "admin-payments", component: () => import("./admin/Payments.vue") },
{ path: "plans", name: "admin-plans", component: () => import("./admin/Plans.vue") },
{ path: "ad-templates", name: "admin-ad-templates", component: () => import("./admin/AdTemplates.vue") },
{ path: "jobs", name: "admin-jobs", component: () => import("./admin/Jobs.vue") },
{ path: "agents", name: "admin-agents", component: () => import("./admin/Agents.vue") },
{ path: "logs", name: "admin-logs", component: () => import("./admin/Logs.vue") },
],
},
],
},
{
@@ -254,6 +271,17 @@ const createAppRouter = () => {
return { name: "login" };
}
}
if (to.matched.some((record) => record.meta.requiresAdmin)) {
if (!auth.user) {
return { name: "login" };
}
const role = String(auth.user.role || "").toLowerCase();
if (role !== "admin") {
return { name: "overview" };
}
}
});
router.afterEach(() => {
loading.finish()

View File

@@ -1,5 +1,6 @@
<script setup lang="tsx">
import { client, type ModelVideo } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import { useUsageQuery } from '@/composables/useUsageQuery';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { computed, onMounted, ref } from 'vue';
@@ -23,16 +24,8 @@ const statsLoading = computed(() => recentVideosLoading.value || (isUsagePending
const fetchDashboardData = async () => {
recentVideosLoading.value = true;
try {
const response = await client.videos.videosList({ page: 1, limit: 5 }, { baseUrl: '/r' });
const body = response.data as any;
const videos = Array.isArray(body?.data?.videos)
? body.data.videos
: Array.isArray(body?.videos)
? body.videos
: [];
recentVideos.value = videos;
const response = await rpcClient.listVideos({ page: 1, limit: 5 });
recentVideos.value = response.videos ?? [];
} catch (err) {
console.error('Failed to fetch dashboard data:', err);
} finally {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ModelVideo } from '@/api/client';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import EmptyState from '@/components/dashboard/EmptyState.vue';
import { formatDate, formatDuration } from '@/lib/utils';
import { useTranslation } from 'i18next-vue';
@@ -112,7 +112,7 @@ const getStatusClass = (status?: string) => {
{{ formatDuration(video.duration) }}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ formatDate(video.created_at) }}
{{ formatDate(video.createdAt) }}
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { client } from '@/api/client';
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';
@@ -38,12 +38,12 @@ interface VastTemplate {
type AdTemplateApiItem = {
id?: string;
name?: string;
vast_tag_url?: string;
ad_format?: 'pre-roll' | 'mid-roll' | 'post-roll';
vastTagUrl?: string;
adFormat?: 'pre-roll' | 'mid-roll' | 'post-roll';
duration?: number | null;
is_active?: boolean;
is_default?: boolean;
created_at?: string;
isActive?: boolean;
isDefault?: boolean;
createdAt?: string;
};
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
@@ -68,21 +68,21 @@ const isMutating = computed(() => saving.value || deletingId.value !== null || t
const canMarkAsDefaultInDialog = computed(() => !isFreePlan.value && (!editingTemplate.value || editingTemplate.value.enabled));
const mapTemplate = (item: AdTemplateApiItem): VastTemplate => ({
id: item.id || `${item.name || 'template'}:${item.vast_tag_url || item.created_at || ''}`,
id: item.id || `${item.name || 'template'}:${item.vastTagUrl || item.createdAt || ''}`,
name: item.name || '',
vastUrl: item.vast_tag_url || '',
adFormat: item.ad_format || 'pre-roll',
vastUrl: item.vastTagUrl || '',
adFormat: item.adFormat || 'pre-roll',
duration: typeof item.duration === 'number' ? item.duration : undefined,
enabled: Boolean(item.is_active),
isDefault: Boolean(item.is_default),
createdAt: item.created_at || '',
enabled: Boolean(item.isActive),
isDefault: Boolean(item.isDefault),
createdAt: item.createdAt || '',
});
const { data: templatesSnapshot, error, isPending, refetch } = useQuery({
key: () => ['settings', 'ad-templates'],
query: async () => {
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
return ((((response.data as any)?.data?.templates) || []) as AdTemplateApiItem[]).map(mapTemplate);
const response = await rpcClient.listAdTemplates();
return (response.templates || []).map(mapTemplate);
},
});
@@ -161,11 +161,12 @@ const openEditDialog = (template: VastTemplate) => {
const buildRequestBody = (enabled = true) => ({
name: formData.value.name.trim(),
vast_tag_url: formData.value.vastUrl.trim(),
ad_format: formData.value.adFormat,
description: '',
vastTagUrl: formData.value.vastUrl.trim(),
adFormat: formData.value.adFormat,
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
is_active: enabled,
is_default: enabled ? formData.value.isDefault : false,
isActive: enabled,
isDefault: enabled ? formData.value.isDefault : false,
});
const handleSave = async () => {
@@ -213,11 +214,10 @@ const handleSave = async () => {
saving.value = true;
try {
if (editingTemplate.value) {
await client.adTemplates.adTemplatesUpdate(
editingTemplate.value.id,
buildRequestBody(editingTemplate.value.enabled),
{ baseUrl: '/r' },
);
await rpcClient.updateAdTemplate({
id: editingTemplate.value.id,
...buildRequestBody(editingTemplate.value.enabled),
});
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.updatedSummary'),
@@ -225,7 +225,7 @@ const handleSave = async () => {
life: 3000,
});
} else {
await client.adTemplates.adTemplatesCreate(buildRequestBody(true), { baseUrl: '/r' });
await rpcClient.createAdTemplate(buildRequestBody(true));
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.createdSummary'),
@@ -249,14 +249,16 @@ const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
togglingId.value = template.id;
try {
await client.adTemplates.adTemplatesUpdate(template.id, {
await rpcClient.updateAdTemplate({
id: template.id,
name: template.name,
vast_tag_url: template.vastUrl,
ad_format: template.adFormat,
description: '',
vastTagUrl: template.vastUrl,
adFormat: template.adFormat,
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
is_active: nextValue,
is_default: nextValue ? template.isDefault : false,
}, { baseUrl: '/r' });
isActive: nextValue,
isDefault: nextValue ? template.isDefault : false,
});
await refetchTemplates();
toast.add({
@@ -285,14 +287,16 @@ const handleSetDefault = async (template: VastTemplate) => {
defaultingId.value = template.id;
try {
await client.adTemplates.adTemplatesUpdate(template.id, {
await rpcClient.updateAdTemplate({
id: template.id,
name: template.name,
vast_tag_url: template.vastUrl,
ad_format: template.adFormat,
description: '',
vastTagUrl: template.vastUrl,
adFormat: template.adFormat,
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
is_active: template.enabled,
is_default: true,
}, { baseUrl: '/r' });
isActive: template.enabled,
isDefault: true,
});
await refetchTemplates();
toast.add({
@@ -320,7 +324,7 @@ const handleDelete = (template: VastTemplate) => {
accept: async () => {
deletingId.value = template.id;
try {
await client.adTemplates.adTemplatesDelete(template.id, { baseUrl: '/r' });
await rpcClient.deleteAdTemplate({ id: template.id });
await refetchTemplates();
toast.add({
severity: 'info',

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { client, type ModelPlan } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import type { PaymentHistoryItem as PaymentHistoryApiItem, Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
@@ -19,30 +20,10 @@ import { computed, ref, watch } from 'vue';
const TERM_OPTIONS = [1, 3, 6, 12] as const;
type UpgradePaymentMethod = 'wallet' | 'topup';
type PlansEnvelope = {
data?: {
plans?: ModelPlan[];
} | ModelPlan[];
};
type PaymentHistoryApiItem = {
id?: string;
amount?: number;
currency?: string;
status?: string;
plan_name?: string;
invoice_id?: string;
kind?: string;
term_months?: number;
payment_method?: string;
expires_at?: string;
created_at?: string;
};
type PaymentHistoryEnvelope = {
data?: {
payments?: PaymentHistoryApiItem[];
};
type InvoiceDownloadResponse = {
filename?: string;
contentType?: string;
content?: string;
};
type PaymentHistoryItem = {
@@ -69,7 +50,7 @@ const { t, i18next } = useTranslation();
const { data: plansResponse, isLoading } = useQuery({
key: () => ['billing-plans'],
query: () => client.plans.plansList({ baseUrl: '/r' }),
query: () => rpcClient.listPlans(),
});
const { data: usageSnapshot, refetch: refetchUsage } = useUsageQuery();
@@ -89,17 +70,7 @@ const purchaseTopupAmount = ref<number | null>(null);
const purchaseLoading = ref(false);
const purchaseError = ref<string | null>(null);
const plans = computed(() => {
const body = plansResponse.value?.data as PlansEnvelope | undefined;
const payload = body?.data;
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === 'object' && Array.isArray(payload.plans)) {
return payload.plans;
}
return [] as ModelPlan[];
});
const plans = computed(() => plansResponse.value?.plans || [] as ModelPlan[]);
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
const currentPlan = computed(() => plans.value.find(plan => plan.id === currentPlanId.value));
@@ -109,11 +80,11 @@ const storageUsed = computed(() => usageSnapshot.value?.totalStorage ?? 0);
const uploadsUsed = computed(() => usageSnapshot.value?.totalVideos ?? 0);
const storageLimit = computed(() => {
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
return activePlan?.storage_limit || 10737418240;
return activePlan?.storageLimit || 10737418240;
});
const uploadsLimit = computed(() => {
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
return activePlan?.upload_limit || 50;
return activePlan?.uploadLimit || 50;
});
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100),
@@ -189,9 +160,9 @@ const formatPaymentMethodLabel = (value?: string) => {
}
};
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit || 0 });
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storageLimit || 0) });
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.durationLimit) });
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.uploadLimit || 0 });
const getStatusStyles = (status: string) => {
switch (status) {
@@ -254,25 +225,25 @@ const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || n
const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
const details: string[] = [];
if (item.kind !== 'wallet_topup' && item.term_months) {
details.push(formatTermLabel(item.term_months));
if (item.kind !== 'wallet_topup' && item.termMonths) {
details.push(formatTermLabel(item.termMonths));
}
if (item.kind !== 'wallet_topup' && item.payment_method) {
details.push(formatPaymentMethodLabel(item.payment_method));
if (item.kind !== 'wallet_topup' && item.paymentMethod) {
details.push(formatPaymentMethodLabel(item.paymentMethod));
}
if (item.kind !== 'wallet_topup' && item.expires_at) {
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expires_at) }));
if (item.kind !== 'wallet_topup' && item.expiresAt) {
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expiresAt) }));
}
return {
id: item.id || '',
date: formatHistoryDate(item.created_at),
date: formatHistoryDate(item.createdAt),
amount: item.amount || 0,
plan: item.kind === 'wallet_topup'
? t('settings.billing.walletTopup')
: (item.plan_name || t('settings.billing.unknownPlan')),
: (item.planName || t('settings.billing.unknownPlan')),
status: normalizeHistoryStatus(item.status),
invoiceId: item.invoice_id || '-',
invoiceId: item.invoiceId || '-',
currency: item.currency || 'USD',
kind: item.kind || 'subscription',
details,
@@ -282,9 +253,8 @@ const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
const loadPaymentHistory = async () => {
historyLoading.value = true;
try {
const response = await client.payments.historyList({ baseUrl: '/r' });
const body = response.data as PaymentHistoryEnvelope | undefined;
paymentHistory.value = (body?.data?.payments || []).map(mapHistoryItem);
const response = await rpcClient.listPaymentHistory();
paymentHistory.value = (response.payments || []).map(mapHistoryItem);
} catch (error) {
console.error(error);
paymentHistory.value = [];
@@ -308,7 +278,7 @@ const refreshBillingState = async () => {
void loadPaymentHistory();
const subscriptionSummary = computed(() => {
const expiresAt = auth.user?.plan_expires_at;
const expiresAt = auth.user?.planExpiresAt || auth.user?.plan_expires_at;
const formattedDate = formatHistoryDate(expiresAt);
if (auth.user?.plan_id) {
@@ -434,16 +404,16 @@ const submitUpgrade = async () => {
try {
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
const payload: Record<string, any> = {
plan_id: selectedPlan.value.id,
term_months: selectedTermMonths.value,
payment_method: paymentMethod,
planId: selectedPlan.value.id,
termMonths: selectedTermMonths.value,
paymentMethod: paymentMethod,
};
if (paymentMethod === 'topup') {
payload.topup_amount = purchaseTopupAmount.value || selectedShortfall.value;
payload.topupAmount = purchaseTopupAmount.value || selectedShortfall.value;
}
await client.payments.paymentsCreate(payload, { baseUrl: '/r' });
await rpcClient.createPayment(payload);
await refreshBillingState();
toast.add({
@@ -481,7 +451,7 @@ const submitUpgrade = async () => {
const handleTopup = async (amount: number) => {
topupLoading.value = true;
try {
await client.wallet.topupsCreate({ amount }, { baseUrl: '/r' });
await rpcClient.topupWallet({ amount });
await refreshBillingState();
toast.add({
@@ -517,13 +487,15 @@ const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
});
try {
const response = await client.payments.invoiceList(item.id, { baseUrl: '/r', format: 'text' });
const content = typeof response.data === 'string' ? response.data : '';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const response = await rpcClient.downloadInvoice({ id: item.id }) as InvoiceDownloadResponse;
const content = response.content || '';
const contentType = response.contentType || 'text/plain;charset=utf-8';
const filename = response.filename || `${item.invoiceId}.txt`;
const blob = new Blob([content], { type: contentType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${item.invoiceId}.txt`;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { client } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/app/AppButton.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
@@ -32,7 +32,7 @@ const handleDeleteAccount = () => {
accept: async () => {
deletingAccount.value = true;
try {
await client.me.deleteMe({ baseUrl: '/r' });
await rpcClient.deleteMe();
auth.$reset();
toast.add({
@@ -66,7 +66,7 @@ const handleClearData = () => {
accept: async () => {
clearingData.value = true;
try {
await client.me.clearDataCreate({ baseUrl: '/r' });
await rpcClient.clearMyData();
await auth.fetchMe();
toast.add({

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { client } from '@/api/client';
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';
@@ -64,8 +64,8 @@ const mapDomainItem = (item: DomainApiItem): DomainItem => ({
const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
key: () => ['settings', 'domains'],
query: async () => {
const response = await client.domains.domainsList({ baseUrl: '/r' });
return ((((response.data as any)?.data?.domains) || []) as DomainApiItem[]).map(mapDomainItem);
const response = await rpcClient.listDomains();
return (response.domains || []).map(mapDomainItem);
},
});
@@ -126,9 +126,9 @@ const handleAddDomain = async () => {
adding.value = true;
try {
await client.domains.domainsCreate({
await rpcClient.createDomain({
name: domainName,
}, { baseUrl: '/r' });
});
await refetchDomains();
closeAddDialog();
@@ -178,7 +178,7 @@ const handleRemoveDomain = (domain: DomainItem) => {
accept: async () => {
removingId.value = domain.id;
try {
await client.domains.domainsDelete(domain.id, { baseUrl: '/r' });
await rpcClient.deleteDomain({ id: domain.id });
await refetchDomains();
toast.add({
severity: 'info',

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { client } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/app/AppButton.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import BellIcon from '@/components/icons/BellIcon.vue';
@@ -90,9 +90,8 @@ const handleSave = async () => {
saving.value = true;
try {
await client.settings.preferencesUpdate(
await rpcClient.updatePreferences(
toNotificationPreferencesPayload(notificationSettings.value),
{ baseUrl: '/r' },
);
await refetchPreferences();

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { client } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/app/AppButton.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
@@ -100,9 +100,8 @@ const handleSave = async () => {
saving.value = true;
try {
await client.settings.preferencesUpdate(
await rpcClient.updatePreferences(
toPlayerPreferencesPayload(playerSettings.value),
{ baseUrl: '/r' },
);
await refetch();

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ModelPlan } from '@/api/client';
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
@@ -44,7 +44,7 @@ const emit = defineEmits<{
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="plan in plans.sort((a,b) => a.price - b.price)"
v-for="plan in plans.sort((a,b) => (a.price || 0) - (b.price || 0))"
:key="plan.id"
:class="[
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { client, type ModelVideo } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import { useAppToast } from '@/composables/useAppToast';
import { computed, ref, watch } from 'vue';
import { useTranslation } from 'i18next-vue';
@@ -21,10 +22,9 @@ const { t } = useTranslation();
const fetchVideo = async () => {
loading.value = true;
try {
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
const videoData = (response.data as any)?.data?.video || (response.data as any)?.data;
if (videoData) {
video.value = videoData;
const response = await rpcClient.getVideo({ id: props.videoId });
if (response.video) {
video.value = response.video;
}
} catch (error) {
console.error('Failed to fetch video:', error);
@@ -44,8 +44,8 @@ const baseUrl = computed(() => typeof window !== 'undefined' ? window.location.o
const shareLinks = computed(() => {
if (!video.value) return [];
const v = video.value;
const playbackPath = v.url || `/play/index/${v.id}`;
const playbackUrl = playbackPath.startsWith('http') ? playbackPath : `${baseUrl.value}${playbackPath}`;
const playbackPath = v.url || '';
const playbackUrl = playbackPath.startsWith('http') ? playbackPath : `${baseUrl.value}/${playbackPath.replace(/^\/+/, '')}`;
return [
{
key: 'embed',

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { client, type ManualAdTemplate, type ModelVideo } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import type { AdTemplate as ManualAdTemplate, Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import { useAppToast } from '@/composables/useAppToast';
import { useAuthStore } from '@/stores/auth';
import { computed, ref, watch } from 'vue';
@@ -53,9 +54,8 @@ const subtitleForm = ref({
const fetchAdTemplates = async () => {
loadingTemplates.value = true;
try {
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
const items = ((response.data as any)?.data?.templates || []) as ManualAdTemplate[];
adTemplates.value = items;
const response = await rpcClient.listAdTemplates();
adTemplates.value = response.templates ?? [];
} catch (error) {
console.error('Failed to fetch ad templates:', error);
} finally {
@@ -66,17 +66,15 @@ const fetchAdTemplates = async () => {
const fetchVideo = async () => {
loading.value = true;
try {
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
const data = (response.data as any)?.data;
const videoData = data?.video || data;
const adConfig = data?.ad_config as AdConfigPayload | undefined;
const response = await rpcClient.getVideo({ id: props.videoId });
const videoData = response.video;
if (videoData) {
video.value = videoData;
currentAdConfig.value = adConfig || null;
currentAdConfig.value = null;
form.value = {
title: videoData.title || '',
adTemplateId: adConfig?.ad_template_id || '',
adTemplateId: '',
};
}
} catch (error) {
@@ -104,26 +102,25 @@ const onFormSubmit = async () => {
if (!validate()) return;
saving.value = true;
try {
const payload: Record<string, any> = {
const response = await rpcClient.updateVideo({
id: props.videoId,
title: form.value.title,
};
description: video.value?.description || '',
url: video.value?.url,
size: video.value?.size,
duration: video.value?.duration,
format: video.value?.format,
status: video.value?.status,
});
if (!isFreePlan.value) {
payload.ad_template_id = form.value.adTemplateId || '';
}
const response = await client.videos.videosUpdate(props.videoId, payload as any, { baseUrl: '/r' });
const data = (response.data as any)?.data;
const updatedVideo = data?.video as ModelVideo | undefined;
const updatedAdConfig = data?.ad_config as AdConfigPayload | undefined;
const updatedVideo = response.video as ModelVideo | undefined;
if (updatedVideo) {
video.value = updatedVideo;
currentAdConfig.value = updatedAdConfig || null;
currentAdConfig.value = null;
form.value = {
title: updatedVideo.title || '',
adTemplateId: updatedAdConfig?.ad_template_id || '',
adTemplateId: '',
};
}

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { client, type ModelVideo } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import EmptyState from '@/components/dashboard/EmptyState.vue';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { createStaticVNode, computed, onMounted, onUnmounted, ref, watch } from 'vue';
@@ -47,15 +48,15 @@ const fetchVideos = async () => {
loading.value = true;
error.value = null;
try {
const response = await client.videos.videosList({
const response = await rpcClient.listVideos({
page: page.value,
limit: limit.value,
search: searchQuery.value || undefined,
status: selectedStatus.value !== 'all' ? selectedStatus.value : undefined,
} as any, { baseUrl: '/r' });
});
videos.value = ((response.data as any)?.data?.videos ?? []) as ModelVideo[];
total.value = (response.data as any)?.data?.total ?? 0;
videos.value = response.videos ?? [];
total.value = response.total ?? 0;
} catch (err: any) {
console.error(err);
error.value = err?.response?.data?.message || err?.message || t('video.page.retry');
@@ -87,7 +88,7 @@ const deleteSelectedVideos = async () => {
selectedVideos.value
.map(v => v.id)
.filter((id): id is string => Boolean(id))
.map(id => client.videos.videosDelete(id, { baseUrl: '/r' }))
.map(id => rpcClient.deleteVideo({ id }))
);
selectedVideos.value = [];
await fetchVideos();
@@ -106,7 +107,7 @@ const deleteVideo = async (videoId?: string) => {
if (!videoId || !confirm(t('video.page.deleteSingleConfirm'))) return;
try {
await client.videos.videosDelete(videoId, { baseUrl: '/r' });
await rpcClient.deleteVideo({ id: videoId });
selectedVideos.value = selectedVideos.value.filter(v => v.id !== videoId);
await fetchVideos();
} catch (err) {

View File

@@ -42,7 +42,7 @@ import LinkIcon from '@/components/icons/LinkIcon.vue';
import PencilIcon from '@/components/icons/PencilIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import EllipsisVerticalIcon from '@/components/icons/EllipsisVerticalIcon.vue';
import type { ModelVideo } from '@/api/client';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import { useAppToast } from '@/composables/useAppToast';
import { computed, nextTick, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
@@ -107,7 +107,7 @@ const handleCopyLink = async () => {
const handleDownload = () => {
if (props.video.id) {
const link = document.createElement('a');
link.href = props.video.hls_path || videoUrl.value;
link.href = props.video.url?.startsWith('http') ? props.video.url : videoUrl.value;
link.download = props.video.title || 'video';
document.body.appendChild(link);
link.click();

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import { useTranslation } from 'i18next-vue';
defineProps<{

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
import { useTranslation } from 'i18next-vue';
import CardPopover from './CardPopover.vue';
@@ -96,7 +96,7 @@ const toggleSelection = (video: ModelVideo) => {
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || t('video.table.noDescription') }}
</p>
<div class="text-xs text-gray-400 mt-auto">
{{ formatDate(video.created_at) }}
{{ formatDate(video.createdAt) }}
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import LinkIcon from '@/components/icons/LinkIcon.vue';
import PencilIcon from '@/components/icons/PencilIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
@@ -120,7 +120,7 @@ const isSelected = (video: ModelVideo) =>
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-500">{{ formatDate(data.created_at, true) }}</span>
<span class="text-sm text-gray-500">{{ formatDate(data.createdAt, true) }}</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-0.5">