init
This commit is contained in:
278
frontend/pages/admin/pushes.vue
Normal file
278
frontend/pages/admin/pushes.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: "admin"
|
||||
});
|
||||
|
||||
type PushSubscriptionsDashboardResponse = {
|
||||
summary: {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
ok: number;
|
||||
ready: number;
|
||||
error: number;
|
||||
};
|
||||
items: Array<{
|
||||
id: string;
|
||||
endpoint: string;
|
||||
endpointHost: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status: "ok" | "ready" | "error" | "inactive";
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "admin" | "user";
|
||||
active: boolean;
|
||||
notificationSetting: {
|
||||
signalsPushEnabled: boolean;
|
||||
resultsPushEnabled: boolean;
|
||||
} | null;
|
||||
};
|
||||
latestEvent: {
|
||||
createdAt: string;
|
||||
level: string;
|
||||
message: string;
|
||||
ok: boolean | null;
|
||||
statusCode: number | null;
|
||||
reason: string | null;
|
||||
notificationType: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
recentNotificationLogs: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
recipients: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const { formatDateTime } = useBrowserDateTime();
|
||||
|
||||
const dashboard = ref<PushSubscriptionsDashboardResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const lastUpdatedAt = ref<Date | null>(null);
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const statusLabel: Record<PushSubscriptionsDashboardResponse["items"][number]["status"], string> = {
|
||||
ok: "OK",
|
||||
ready: "Ready",
|
||||
error: "Error",
|
||||
inactive: "Inactive"
|
||||
};
|
||||
|
||||
const statusBadgeClass = (status: PushSubscriptionsDashboardResponse["items"][number]["status"]) => {
|
||||
if (status === "error") {
|
||||
return "signal-row__badge--manual_review";
|
||||
}
|
||||
|
||||
if (status === "inactive") {
|
||||
return "signal-row__badge--inactive";
|
||||
}
|
||||
|
||||
return "signal-row__badge--win";
|
||||
};
|
||||
|
||||
const staleSubscriptions = computed(() => {
|
||||
if (!dashboard.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return dashboard.value.items.filter((item) => {
|
||||
const providerExpired =
|
||||
item.latestEvent?.statusCode === 404 ||
|
||||
item.latestEvent?.statusCode === 410;
|
||||
|
||||
return item.status === "inactive" || providerExpired;
|
||||
});
|
||||
});
|
||||
|
||||
const healthySubscriptions = computed(() => {
|
||||
if (!dashboard.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const staleIds = new Set(staleSubscriptions.value.map((item) => item.id));
|
||||
return dashboard.value.items.filter((item) => !staleIds.has(item.id));
|
||||
});
|
||||
|
||||
const loadDashboard = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
dashboard.value = await useApi<PushSubscriptionsDashboardResponse>("/admin/push-subscriptions");
|
||||
lastUpdatedAt.value = new Date();
|
||||
errorMessage.value = "";
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Failed to load push dashboard";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboard();
|
||||
|
||||
refreshTimer = setInterval(() => {
|
||||
void loadDashboard();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page admin-grid">
|
||||
<div class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1>Push subscriptions</h1>
|
||||
<p class="muted">Auto-refresh every 5 seconds with the latest delivery state for each subscription.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<NuxtLink class="topbar__link" to="/admin">Back to admin</NuxtLink>
|
||||
<button :disabled="loading" @click="loadDashboard">{{ loading ? "Refreshing..." : "Refresh now" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="lastUpdatedAt" class="muted">Last updated: {{ formatDateTime(lastUpdatedAt) }}</p>
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="admin-summary-grid">
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Total</span>
|
||||
<strong>{{ dashboard.summary.total }}</strong>
|
||||
<p class="muted">subscriptions in database</p>
|
||||
</div>
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Active</span>
|
||||
<strong>{{ dashboard.summary.active }}</strong>
|
||||
<p class="muted">eligible for delivery</p>
|
||||
</div>
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Ready</span>
|
||||
<strong>{{ dashboard.summary.ready }}</strong>
|
||||
<p class="muted">active without delivery history</p>
|
||||
</div>
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Errors</span>
|
||||
<strong>{{ dashboard.summary.error }}</strong>
|
||||
<p class="muted">last attempt failed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>Stale or dropped subscriptions</h2>
|
||||
<p class="muted">This block highlights subscriptions that are already inactive or were rejected by the push provider with 404/410.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="staleSubscriptions.length" class="admin-list">
|
||||
<article v-for="item in staleSubscriptions" :key="item.id" class="push-row">
|
||||
<div class="push-row__main">
|
||||
<div class="push-row__heading">
|
||||
<strong>{{ item.user.email }}</strong>
|
||||
<span class="signal-row__badge" :class="statusBadgeClass(item.status)">
|
||||
{{ statusLabel[item.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">{{ item.endpointHost }}</p>
|
||||
<p class="push-row__endpoint">{{ item.endpoint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__meta">
|
||||
<p><strong>Signals push:</strong> {{ item.user.notificationSetting?.signalsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Results push:</strong> {{ item.user.notificationSetting?.resultsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Created:</strong> {{ formatDateTime(item.createdAt) }}</p>
|
||||
<p><strong>Updated:</strong> {{ formatDateTime(item.updatedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__event">
|
||||
<template v-if="item.latestEvent">
|
||||
<p><strong>Last event:</strong> {{ formatDateTime(item.latestEvent.createdAt) }}</p>
|
||||
<p><strong>Type:</strong> {{ item.latestEvent.notificationType ?? "unknown" }}</p>
|
||||
<p><strong>Status code:</strong> {{ item.latestEvent.statusCode ?? "n/a" }}</p>
|
||||
<p><strong>Reason:</strong> {{ item.latestEvent.reason ?? item.latestEvent.message }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="muted">No delivery attempts yet.</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="muted">No stale subscriptions right now.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>All other subscriptions</h2>
|
||||
<p class="muted">OK means the last delivery succeeded. Ready means the subscription is active but has not been used yet.</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
<article v-for="item in healthySubscriptions" :key="item.id" class="push-row">
|
||||
<div class="push-row__main">
|
||||
<div class="push-row__heading">
|
||||
<strong>{{ item.user.email }}</strong>
|
||||
<span class="signal-row__badge" :class="statusBadgeClass(item.status)">
|
||||
{{ statusLabel[item.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">{{ item.endpointHost }}</p>
|
||||
<p class="push-row__endpoint">{{ item.endpoint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__meta">
|
||||
<p><strong>Signals push:</strong> {{ item.user.notificationSetting?.signalsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Results push:</strong> {{ item.user.notificationSetting?.resultsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Created:</strong> {{ formatDateTime(item.createdAt) }}</p>
|
||||
<p><strong>Updated:</strong> {{ formatDateTime(item.updatedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__event">
|
||||
<template v-if="item.latestEvent">
|
||||
<p><strong>Last event:</strong> {{ formatDateTime(item.latestEvent.createdAt) }}</p>
|
||||
<p><strong>Type:</strong> {{ item.latestEvent.notificationType ?? "unknown" }}</p>
|
||||
<p><strong>Status code:</strong> {{ item.latestEvent.statusCode ?? "n/a" }}</p>
|
||||
<p><strong>Reason:</strong> {{ item.latestEvent.reason ?? item.latestEvent.message }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="muted">No delivery attempts yet.</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>Recent notification batches</h2>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
<div v-for="log in dashboard.recentNotificationLogs" :key="log.id" class="admin-row">
|
||||
<div>
|
||||
<strong>{{ log.type }}</strong>
|
||||
<p class="muted">{{ formatDateTime(log.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<span class="muted">Recipients: {{ log.recipients }}</span>
|
||||
<span class="muted">Success: {{ log.successCount }}</span>
|
||||
<span class="muted">Failed: {{ log.failedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user