feat: zentrale selfhost-dienste bereitstellen

This commit is contained in:
2026-08-02 16:34:10 +02:00
parent d3ad53bcf0
commit 8b8e0c97d3
30 changed files with 1818 additions and 74 deletions

View File

@@ -1,5 +1,5 @@
type FetchOptions = {
method?: "GET" | "POST" | "PATCH" | "DELETE";
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
};

View File

@@ -6,7 +6,8 @@ const { data, pending, refresh, error } = await useAsyncData("summary", () => pu
instances: number;
devices: number;
jobs: number;
failedJobs: number;
failedJobs: number;
serviceUnits: number;
}>("/admin/summary"), { immediate: false });
onMounted(async () => {
@@ -26,12 +27,12 @@ watch(error, (nextError) => {
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-950">Übersicht</h2>
<p class="text-sm text-gray-500">Technischer Zustand des zentralen Push-Gateways.</p>
<p class="text-sm text-gray-500">Technischer Zustand des zentralen FEDEO-Service-Gateways.</p>
</div>
<UButton icon="i-lucide-refresh-cw" variant="soft" :loading="pending" @click="refresh()">Aktualisieren</UButton>
</div>
<div class="grid gap-4 md:grid-cols-4">
<div class="grid gap-4 md:grid-cols-5">
<UCard>
<p class="text-sm text-gray-500">Instanzen</p>
<p class="mt-2 text-3xl font-bold">{{ data?.instances ?? 0 }}</p>
@@ -48,6 +49,10 @@ watch(error, (nextError) => {
<p class="text-sm text-gray-500">Fehler</p>
<p class="mt-2 text-3xl font-bold text-red-600">{{ data?.failedJobs ?? 0 }}</p>
</UCard>
<UCard>
<p class="text-sm text-gray-500">Service-Einheiten</p>
<p class="mt-2 text-3xl font-bold">{{ data?.serviceUnits ?? 0 }}</p>
</UCard>
</div>
</div>
</TokenGate>

View File

@@ -4,6 +4,7 @@ const pushApi = usePushApi();
const toast = useToast();
const id = route.params.id as string;
const testSubmitting = ref(false);
const serviceSaving = ref("");
const selectedDeviceIds = ref<string[]>([]);
const testForm = reactive({
title: "FEDEO Push-Test",
@@ -13,6 +14,12 @@ const testForm = reactive({
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: jobs, refresh: refreshJobs } = await useAsyncData(`jobs-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/jobs`), { default: () => [], immediate: false });
const { data: entitlements, refresh: refreshEntitlements } = await useAsyncData(`services-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/services`), { default: () => [], immediate: false });
const { data: usage, refresh: refreshUsage } = await useAsyncData(`usage-${id}`, () => pushApi.request<Record<string, any>[]>(`/admin/instances/${id}/usage?limit=100`), { default: () => [], immediate: false });
const serviceForms = reactive({
ai: { enabled: false, monthlyLimit: null as number | null, unitPriceMicros: 0 },
banking: { enabled: false, monthlyLimit: null as number | null, unitPriceMicros: 0 },
});
const activeDevices = computed(() => (devices.value || []).filter((device) => device.status === "active"));
onMounted(async () => {
@@ -23,7 +30,30 @@ onMounted(async () => {
});
async function refreshAll() {
await Promise.all([refreshInstance(), refreshDevices(), refreshJobs()]);
await Promise.all([refreshInstance(), refreshDevices(), refreshJobs(), refreshEntitlements(), refreshUsage()]);
for (const entitlement of entitlements.value || []) {
const form = serviceForms[entitlement.service as "ai" | "banking"];
if (form) Object.assign(form, { enabled: entitlement.enabled, monthlyLimit: entitlement.monthlyLimit, unitPriceMicros: entitlement.unitPriceMicros });
}
}
async function saveService(service: "ai" | "banking") {
serviceSaving.value = service;
try {
const form = serviceForms[service];
await pushApi.request(`/admin/instances/${id}/services/${service}`, {
method: "PUT",
body: {
enabled: form.enabled,
monthlyLimit: Number(form.monthlyLimit) > 0 ? Number(form.monthlyLimit) : null,
unitPriceMicros: Math.max(0, Number(form.unitPriceMicros) || 0),
},
});
await Promise.all([refreshEntitlements(), refreshUsage()]);
toast.add({ title: `${service === "ai" ? "KI" : "Banking"}-Dienst gespeichert`, color: "success" });
} finally {
serviceSaving.value = "";
}
}
function toggleDevice(centralDeviceId: string, checked: boolean) {
@@ -96,6 +126,39 @@ async function sendTestPush() {
</UCard>
</div>
<UCard>
<template #header><h3 class="font-bold">Zentrale Dienste und Abrechnung</h3></template>
<div class="grid gap-4 lg:grid-cols-2">
<div v-for="service in (['ai', 'banking'] as const)" :key="service" class="space-y-3 rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-semibold">{{ service === 'ai' ? 'KI' : 'Banking' }}</p>
<p class="text-xs text-gray-500">{{ service === 'ai' ? 'Einheit: Provider-Token' : 'Einheit: Provider-Aufruf' }}</p>
</div>
<USwitch v-model="serviceForms[service].enabled" />
</div>
<UFormField label="Monatslimit (leer = unbegrenzt)">
<UInput v-model.number="serviceForms[service].monthlyLimit" type="number" min="1" />
</UFormField>
<UFormField label="Preis pro Einheit in Mikro-Euro">
<UInput v-model.number="serviceForms[service].unitPriceMicros" type="number" min="0" />
</UFormField>
<UButton :loading="serviceSaving === service" @click="saveService(service)">Speichern</UButton>
</div>
</div>
<div class="mt-6 overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="text-gray-500"><tr><th class="py-2">Zeit</th><th>Dienst</th><th>Vorgang</th><th>Status</th><th>Einheiten</th><th>Kosten</th></tr></thead>
<tbody>
<tr v-for="event in usage" :key="event.id" class="border-t border-gray-100">
<td class="py-2">{{ new Date(event.createdAt).toLocaleString() }}</td><td>{{ event.service }}</td><td>{{ event.operation }}</td><td>{{ event.status }}</td><td>{{ event.units }}</td><td>{{ (Number(event.costMicros || 0) / 1_000_000).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) }}</td>
</tr>
<tr v-if="!usage?.length"><td colspan="6" class="py-4 text-gray-500">Noch keine Nutzung erfasst.</td></tr>
</tbody>
</table>
</div>
</UCard>
<UCard>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">