Files
talorr cda36918e8 init
2026-03-27 03:36:08 +03:00

279 lines
9.4 KiB
Vue

<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>