KI-AGENT: Push Test im Admin Dashboard ergänzen
This commit is contained in:
@@ -1,11 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const pushApi = usePushApi();
|
const pushApi = usePushApi();
|
||||||
|
const toast = useToast();
|
||||||
const id = route.params.id as string;
|
const id = route.params.id as string;
|
||||||
|
const testSubmitting = ref(false);
|
||||||
|
const selectedDeviceIds = ref<string[]>([]);
|
||||||
|
const testForm = reactive({
|
||||||
|
title: "FEDEO Push-Test",
|
||||||
|
body: "Diese Testnachricht wurde über das Push-Admin-Dashboard gesendet.",
|
||||||
|
});
|
||||||
|
|
||||||
const { data: instance, refresh: refreshInstance } = await useAsyncData(`instance-${id}`, () => pushApi.request<Record<string, any>>(`/admin/instances/${id}`), { immediate: false });
|
const { data: instance, refresh: refreshInstance } = await useAsyncData(`instance-${id}`, () => pushApi.request<Record<string, any>>(`/admin/instances/${id}`), { immediate: false });
|
||||||
const { data: devices, refresh: refreshDevices } = await useAsyncData(`devices-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/devices`), { default: () => [], immediate: false });
|
const { data: devices, refresh: refreshDevices } = await useAsyncData(`devices-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/devices`), { default: () => [], immediate: false });
|
||||||
const { data: jobs, refresh: refreshJobs } = await useAsyncData(`jobs-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/jobs`), { default: () => [], immediate: false });
|
const { data: jobs, refresh: refreshJobs } = await useAsyncData(`jobs-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/jobs`), { default: () => [], immediate: false });
|
||||||
|
const activeDevices = computed(() => (devices.value || []).filter((device) => device.status === "active"));
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
pushApi.hydrateToken();
|
pushApi.hydrateToken();
|
||||||
@@ -17,6 +25,47 @@ onMounted(async () => {
|
|||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
await Promise.all([refreshInstance(), refreshDevices(), refreshJobs()]);
|
await Promise.all([refreshInstance(), refreshDevices(), refreshJobs()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleDevice(centralDeviceId: string, checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
selectedDeviceIds.value = [...new Set([...selectedDeviceIds.value, centralDeviceId])];
|
||||||
|
} else {
|
||||||
|
selectedDeviceIds.value = selectedDeviceIds.value.filter((id) => id !== centralDeviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllActiveDevices() {
|
||||||
|
selectedDeviceIds.value = activeDevices.value.map((device) => device.centralDeviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestPush() {
|
||||||
|
testSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
const result = await pushApi.request<Record<string, any>>(`/admin/instances/${id}/test-push`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
title: testForm.title,
|
||||||
|
body: testForm.body,
|
||||||
|
deviceIds: selectedDeviceIds.value.length ? selectedDeviceIds.value : undefined,
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Promise.all([refreshInstance(), refreshJobs(), refreshDevices()]);
|
||||||
|
toast.add({
|
||||||
|
title: "Test-Push gesendet",
|
||||||
|
description: `${result.sent ?? 0} gesendet, ${result.failed ?? 0} fehlgeschlagen`,
|
||||||
|
color: result.failed ? "warning" : "success",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.add({
|
||||||
|
title: "Test-Push fehlgeschlagen",
|
||||||
|
description: error?.data?.message || error?.message || "Die Testnachricht konnte nicht gesendet werden.",
|
||||||
|
color: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
testSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -47,6 +96,54 @@ async function refreshAll() {
|
|||||||
</UCard>
|
</UCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h3 class="font-bold">Testnachricht</h3>
|
||||||
|
<UBadge color="neutral" variant="soft">{{ selectedDeviceIds.length || activeDevices.length }} Zielgeräte</UBadge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid gap-4 lg:grid-cols-[1fr_240px]">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UFormField label="Titel">
|
||||||
|
<UInput v-model="testForm.title" maxlength="120" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Nachricht">
|
||||||
|
<UTextarea v-model="testForm.body" :rows="3" maxlength="240" />
|
||||||
|
</UFormField>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-send"
|
||||||
|
:loading="testSubmitting"
|
||||||
|
:disabled="!activeDevices.length || !testForm.title.trim() || !testForm.body.trim()"
|
||||||
|
@click="sendTestPush"
|
||||||
|
>
|
||||||
|
Test senden
|
||||||
|
</UButton>
|
||||||
|
<UButton icon="i-lucide-check-check" variant="soft" :disabled="!activeDevices.length" @click="selectAllActiveDevices">
|
||||||
|
Alle aktiven auswählen
|
||||||
|
</UButton>
|
||||||
|
<UButton icon="i-lucide-x" variant="ghost" :disabled="!selectedDeviceIds.length" @click="selectedDeviceIds = []">
|
||||||
|
Auswahl leeren
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-200 p-3">
|
||||||
|
<p class="mb-3 text-sm font-medium text-gray-700">Zielgeräte</p>
|
||||||
|
<div class="max-h-48 space-y-2 overflow-auto">
|
||||||
|
<label v-for="device in activeDevices" :key="device.id" class="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<UCheckbox
|
||||||
|
:model-value="selectedDeviceIds.includes(device.centralDeviceId)"
|
||||||
|
@update:model-value="toggleDevice(device.centralDeviceId, Boolean($event))"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 truncate font-mono">{{ device.centralDeviceId }}</span>
|
||||||
|
</label>
|
||||||
|
<p v-if="!activeDevices.length" class="text-sm text-gray-500">Keine aktiven Geräte vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UCard>
|
<UCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="font-bold">Geräte</h3>
|
<h3 class="font-bold">Geräte</h3>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { db } from "../db/client.js";
|
|||||||
import { requireAdmin } from "../lib/auth.js";
|
import { requireAdmin } from "../lib/auth.js";
|
||||||
import { encryptSecret } from "../lib/crypto.js";
|
import { encryptSecret } from "../lib/crypto.js";
|
||||||
import { createClientSecret, createPublicId, previewSecret } from "../lib/ids.js";
|
import { createClientSecret, createPublicId, previewSecret } from "../lib/ids.js";
|
||||||
|
import { deliverJob } from "../services/delivery.js";
|
||||||
|
|
||||||
const createInstanceSchema = z.object({
|
const createInstanceSchema = z.object({
|
||||||
name: z.string().min(2),
|
name: z.string().min(2),
|
||||||
@@ -21,6 +22,13 @@ const updateInstanceSchema = createInstanceSchema.partial().extend({
|
|||||||
status: z.enum(["active", "blocked", "disabled"]).optional(),
|
status: z.enum(["active", "blocked", "disabled"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const testPushSchema = z.object({
|
||||||
|
deviceIds: z.array(z.string().min(1)).max(100).optional(),
|
||||||
|
title: z.string().min(1).max(120).default("FEDEO Push-Test"),
|
||||||
|
body: z.string().min(1).max(240).default("Diese Testnachricht wurde über das Push-Admin-Dashboard gesendet."),
|
||||||
|
priority: z.enum(["normal", "high"]).default("high"),
|
||||||
|
});
|
||||||
|
|
||||||
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.addHook("preHandler", requireAdmin);
|
app.addHook("preHandler", requireAdmin);
|
||||||
|
|
||||||
@@ -122,6 +130,72 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return await db.select().from(deliveryJobs).where(eq(deliveryJobs.instanceId, params.id)).orderBy(desc(deliveryJobs.createdAt)).limit(100);
|
return await db.select().from(deliveryJobs).where(eq(deliveryJobs.instanceId, params.id)).orderBy(desc(deliveryJobs.createdAt)).limit(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/admin/instances/:id/test-push", async (request, reply) => {
|
||||||
|
const params = z.object({ id: z.string().uuid() }).parse(request.params);
|
||||||
|
const body = testPushSchema.parse(request.body || {});
|
||||||
|
const [instance] = await db.select().from(pushInstances).where(eq(pushInstances.id, params.id)).limit(1);
|
||||||
|
if (!instance) return reply.code(404).send({ error: "instance_not_found" });
|
||||||
|
|
||||||
|
const devices = await db
|
||||||
|
.select()
|
||||||
|
.from(pushDevices)
|
||||||
|
.where(and(eq(pushDevices.instanceId, instance.id), eq(pushDevices.status, "active")));
|
||||||
|
const requested = body.deviceIds?.length ? new Set(body.deviceIds) : null;
|
||||||
|
const acceptedDevices = devices
|
||||||
|
.map((device) => device.centralDeviceId)
|
||||||
|
.filter((centralDeviceId) => !requested || requested.has(centralDeviceId));
|
||||||
|
|
||||||
|
if (!acceptedDevices.length) {
|
||||||
|
return reply.code(400).send({ error: "no_active_devices", message: "Für diese Auswahl wurden keine aktiven Geräte gefunden." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejected = requested ? body.deviceIds!.length - acceptedDevices.length : 0;
|
||||||
|
const [job] = await db.insert(deliveryJobs).values({
|
||||||
|
deliveryJobId: createPublicId("job"),
|
||||||
|
instanceId: instance.id,
|
||||||
|
idempotencyKey: `admin-test:${instance.id}:${Date.now()}`,
|
||||||
|
priority: body.priority,
|
||||||
|
ttlSeconds: 600,
|
||||||
|
collapseKey: "admin-test",
|
||||||
|
acceptedCount: acceptedDevices.length,
|
||||||
|
rejectedCount: rejected,
|
||||||
|
status: "processing",
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
await deliverJob(job, acceptedDevices, {
|
||||||
|
priority: body.priority,
|
||||||
|
ttlSeconds: 600,
|
||||||
|
collapseKey: "admin-test",
|
||||||
|
notification: {
|
||||||
|
title: body.title,
|
||||||
|
body: body.body,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: "admin.test_push",
|
||||||
|
deliveryJobId: job.deliveryJobId,
|
||||||
|
instanceId: instance.instanceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await audit("admin", "instance.test_push.sent", instance.id, {
|
||||||
|
deliveryJobId: job.deliveryJobId,
|
||||||
|
accepted: acceptedDevices.length,
|
||||||
|
rejected,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updatedJob] = await db.select().from(deliveryJobs).where(eq(deliveryJobs.id, job.id)).limit(1);
|
||||||
|
return reply.code(202).send({
|
||||||
|
deliveryJobId: job.deliveryJobId,
|
||||||
|
accepted: acceptedDevices.length,
|
||||||
|
rejected,
|
||||||
|
status: updatedJob?.status || "processing",
|
||||||
|
sent: updatedJob?.sentCount || 0,
|
||||||
|
failed: updatedJob?.failedCount || 0,
|
||||||
|
lastErrorCode: updatedJob?.lastErrorCode,
|
||||||
|
lastErrorMessage: updatedJob?.lastErrorMessage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/admin/jobs", async () => {
|
app.get("/admin/jobs", async () => {
|
||||||
return await db.select({
|
return await db.select({
|
||||||
id: deliveryJobs.id,
|
id: deliveryJobs.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user