Files
crowd-funder-for-time-pwa/src/components/dev/NotificationDebugPanel.vue
T

583 lines
19 KiB
Vue

<template>
<section class="bg-slate-100 rounded-md overflow-hidden px-4 py-4">
<!-- Backend testing -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Backend Testing</h2>
<label class="block text-sm font-medium text-slate-700 mb-1">
Notification Backend URL
</label>
<input
v-model="backendUrlDraft"
type="url"
class="w-full text-sm px-3 py-2 rounded border border-slate-300 bg-white mb-1"
placeholder="Leave empty for default (DEFAULT_NOTIFY_API_SERVER)"
:disabled="busy"
@keydown.enter="onSaveBackendUrl"
/>
<p class="text-xs text-slate-500 mb-2">
Active:
<code class="text-[11px] break-all">{{ activeBackendUrl }}</code>
</p>
<button
class="w-full text-sm mb-4 px-3 py-2 rounded border border-slate-300 bg-white"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onSaveBackendUrl"
>
Save Backend URL
</button>
<label class="flex items-center gap-2 text-sm mb-4 cursor-pointer">
<input
v-model="testModeEnabled"
type="checkbox"
class="rounded border-slate-300"
:disabled="busy"
@change="onTestModeChange"
/>
<span>Test Mode</span>
</label>
<label class="flex items-center gap-2 text-sm mb-1 cursor-pointer">
<input
v-model="bypassAuthEnabled"
type="checkbox"
class="rounded border-slate-300"
:disabled="busy"
@change="onBypassAuthChange"
/>
<span>Skip JWT Authentication (Local Development Only)</span>
</label>
<p class="text-xs text-slate-500 mb-4">
Enable only when using a local development server (for example localhost
or ngrok) that intentionally accepts unauthenticated notification
requests.
</p>
<div class="flex flex-col gap-2 mb-4">
<button
class="w-full text-md bg-gradient-to-b from-emerald-400 to-emerald-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onRegisterToken"
>
Register Token Now
</button>
<button
class="w-full text-md bg-gradient-to-b from-cyan-500 to-cyan-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onUploadAlertAuthorization"
>
Upload AlertSearch Authorization
</button>
<p
v-if="alertAuthorizationStatus"
class="text-xs rounded px-3 py-2 border"
:class="
alertAuthorizationStatus.ok
? 'text-emerald-900 bg-emerald-50 border-emerald-200'
: 'text-rose-900 bg-rose-50 border-rose-200'
"
role="status"
>
{{ alertAuthorizationStatus.message }}
</p>
<p v-else class="text-xs text-slate-500">
Manually mints and uploads 100 delegated day JWTs. Requires an active
did:ethr identity and JWT authentication; Test Mode is not used.
</p>
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onBackendRefresh"
>
Refresh Notifications
</button>
<button
class="w-full text-md bg-gradient-to-b from-violet-400 to-violet-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onSimulateWakeupRefresh"
>
Simulate WAKEUP_PING (Local)
</button>
<p class="text-xs text-slate-500">
Local simulation only — calls the refresh API directly (no FCM push).
</p>
<button
class="w-full text-md bg-gradient-to-b from-amber-400 to-amber-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onSendRealWakeupPing"
>
Send Real WAKEUP_PING
</button>
<p
v-if="realWakeupStatus"
class="text-xs rounded px-3 py-2 border"
:class="
realWakeupStatus.ok
? 'text-emerald-900 bg-emerald-50 border-emerald-200'
: 'text-rose-900 bg-rose-50 border-rose-200'
"
role="status"
>
{{ realWakeupStatus.message }}
</p>
<p v-else class="text-xs text-slate-500">
Full pipeline — backend `/debug/send-wakeup` → FCM → WAKEUP_PING
handler.
</p>
</div>
<div class="mb-4">
<h3 class="text-sm font-bold mb-1">Current FCM Token</h3>
<div
v-if="fcmToken"
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs font-mono break-all flex gap-2 items-start"
>
<span class="min-w-0 flex-1">{{ truncatedFcmToken }}</span>
<button
type="button"
class="shrink-0 text-sm px-2 py-1 rounded border border-slate-300 bg-slate-50"
@click="onCopyFcmToken"
>
Copy
</button>
</div>
<p
v-else
class="text-sm text-slate-500 bg-white rounded px-3 py-2 border border-slate-200"
>
(not available — try Register Token Now on native)
</p>
</div>
<div class="mb-2">
<h3 class="text-sm font-bold mb-1">Backend Status</h3>
<dl
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs space-y-1"
>
<div class="flex gap-2">
<dt class="text-slate-500 shrink-0">URL</dt>
<dd class="break-all font-mono">{{ activeBackendUrl }}</dd>
</div>
<div class="flex gap-2">
<dt class="text-slate-500 shrink-0">testMode</dt>
<dd>{{ testModeEnabled ? "true" : "false" }}</dd>
</div>
<div class="flex gap-2">
<dt class="text-slate-500 shrink-0">bypassAuth</dt>
<dd>{{ bypassAuthEnabled ? "true" : "false" }}</dd>
</div>
</dl>
</div>
</div>
<!-- SECTION F: Mock Timing Presets -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Timing Presets</h2>
<div class="flex flex-wrap gap-2">
<button
v-for="preset in presets"
:key="preset.ms"
class="px-3 py-2 rounded border border-slate-300 bg-white text-sm"
:class="{
'border-blue-500 ring-1 ring-blue-300': intervalMs === preset.ms,
}"
@click="intervalMs = preset.ms"
>
{{ preset.label }}
</button>
</div>
<div class="text-xs text-slate-500 mt-2">
Selected interval: <b>{{ intervalLabel }}</b>
</div>
</div>
<!-- SECTION A: Mock Refresh Controls -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Mock Refresh Controls</h2>
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onMockRefresh"
>
Trigger Mock Refresh
</button>
</div>
<!-- SECTION B: Wakeup Ping Simulator -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Wakeup Ping Simulator</h2>
<p class="text-xs text-slate-500 mb-2">
Exercises the production push handler (not the refresh API shortcut
above).
</p>
<button
class="w-full text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onWakeupPing"
>
Simulate WAKEUP_PING
</button>
</div>
<!-- SECTION C: Flood Test -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Flood Test</h2>
<button
class="w-full text-md bg-gradient-to-b from-rose-400 to-rose-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onFloodTest"
>
Run 20 Refreshes
</button>
</div>
<!-- SECTION D: Pending Notification Inspector -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<h2 class="font-bold">Pending Notification Inspector</h2>
<button
class="ms-auto text-sm px-3 py-2 rounded border border-slate-300 bg-white"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="refreshPending"
>
Refresh
</button>
</div>
<div
v-if="pendingInspectorMessage"
class="text-sm text-amber-900 bg-amber-50 rounded px-3 py-2 border border-amber-200"
role="status"
>
{{ pendingInspectorMessage }}
</div>
<div
v-else-if="pending.length === 0"
class="text-sm text-slate-500 bg-white rounded px-3 py-2 border border-slate-200"
>
(none)
</div>
<ul v-else class="bg-white rounded border border-slate-200 divide-y">
<li
v-for="p in pending"
:key="p.identifier"
class="px-3 py-2 text-sm flex gap-3 items-start"
>
<code class="truncate min-w-0">{{ p.identifier }}</code>
<span
class="ms-auto text-xs text-right text-slate-600 max-w-[58%] shrink-0"
>
<template v-if="p.wallClockMillis != null">
<span class="block font-medium">{{
formatIsoMs(p.wallClockMillis)
}}</span>
<span class="block text-[10px] text-slate-400"
>Scheduled target ({{ p.wallClockSource }})</span
>
<span
v-if="
p.nextTriggerDate != null &&
Math.abs(p.nextTriggerDate - p.wallClockMillis) > 5000
"
class="block text-[10px] text-amber-800 mt-0.5"
>iOS nextTriggerDate (resamples on each fetch for interval
triggers): {{ formatIsoMs(p.nextTriggerDate) }}</span
>
</template>
<template v-else>
<span class="block">{{
formatIsoMs(p.nextTriggerDate ?? null)
}}</span>
<span class="block text-[10px] text-slate-400"
>iOS nextTriggerDate</span
>
</template>
</span>
</li>
</ul>
</div>
<!-- SECTION E: Clear Notifications -->
<div class="mb-6">
<h2 class="mb-2 font-bold">Clear Notifications</h2>
<button
class="w-full text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
:disabled="busy"
:class="{ 'opacity-50 cursor-not-allowed': busy }"
@click="onClearNotifications"
>
Clear Notifications
</button>
</div>
<!-- SECTION G: Event Log -->
<div>
<div class="flex items-center gap-3 mb-2">
<h2 class="font-bold">Event Log</h2>
<button
class="ms-auto text-sm px-3 py-2 rounded border border-slate-300 bg-white"
@click="NotificationDebugService.clearDebugLogs()"
>
Clear Log
</button>
</div>
<div
class="bg-white rounded border border-slate-200 px-3 py-2 text-xs font-mono whitespace-pre-wrap min-h-[8rem]"
>
<div v-if="eventLog.length === 0" class="text-slate-400">(empty)</div>
<div v-for="(line, idx) in eventLog" v-else :key="idx">
{{ line }}
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { copyToClipboard } from "@/services/ClipboardService";
import { subscribe } from "@/services/notifications/NotificationDebugEvents";
import { NotificationDebugService } from "@/services/notifications/NotificationDebugService";
type PendingInfo = {
identifier: string;
nextTriggerDate?: number | null;
triggerType?: string | null;
wallClockMillis?: number | null;
wallClockSource?: string | null;
};
const presets = [
{ label: "30 sec", ms: 30_000 },
{ label: "1 min", ms: 60_000 },
{ label: "5 min", ms: 5 * 60_000 },
{ label: "10 min", ms: 10 * 60_000 },
];
const intervalMs = ref<number>(60_000);
const busy = ref(false);
const pending = ref<PendingInfo[]>([]);
const pendingInspectorMessage = ref<string | null>(null);
const backendUrlDraft = ref("");
const testModeEnabled = ref(NotificationDebugService.isTestModeEnabled());
const bypassAuthEnabled = ref(NotificationDebugService.isBypassAuthEnabled());
const fcmToken = ref<string | null>(NotificationDebugService.getFcmToken());
const activeBackendUrl = ref(NotificationDebugService.getActiveBackendUrl());
const realWakeupStatus = ref<{ ok: boolean; message: string } | null>(null);
const alertAuthorizationStatus = ref<{
ok: boolean;
message: string;
} | null>(null);
const truncatedFcmToken = computed(() => {
const t = fcmToken.value?.trim() ?? "";
if (!t) {
return "";
}
if (t.length <= 24) {
return t;
}
return `${t.slice(0, 12)}…${t.slice(-8)}`;
});
const eventLog = ref<string[]>([]);
let unsubscribeEventLog: (() => void) | undefined;
const intervalLabel = computed(() => {
const preset = presets.find((p) => p.ms === intervalMs.value);
return preset?.label ?? `${intervalMs.value}ms`;
});
function formatIsoMs(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms)) {
return "";
}
return new Date(ms).toISOString();
}
async function withBusy(fn: () => Promise<void>): Promise<void> {
if (busy.value) return;
busy.value = true;
try {
await fn();
} finally {
busy.value = false;
}
}
async function refreshPending(): Promise<void> {
const result = await NotificationDebugService.getPendingNotifications();
pending.value = result.pending;
pendingInspectorMessage.value = result.inspectorUnavailableMessage ?? null;
}
async function onMockRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerMockRefresh(intervalMs.value);
await refreshPending();
});
}
async function onWakeupPing(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupPing();
await refreshPending();
});
}
async function onFloodTest(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.runFloodTest(intervalMs.value);
await refreshPending();
});
}
async function onClearNotifications(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.clearNotifications();
await refreshPending();
});
}
function syncBackendState(): void {
backendUrlDraft.value =
NotificationDebugService.getBackendUrlOverride() ?? "";
testModeEnabled.value = NotificationDebugService.isTestModeEnabled();
bypassAuthEnabled.value = NotificationDebugService.isBypassAuthEnabled();
fcmToken.value = NotificationDebugService.getFcmToken();
activeBackendUrl.value = NotificationDebugService.getActiveBackendUrl();
}
function onSaveBackendUrl(): void {
NotificationDebugService.saveBackendBaseUrl(backendUrlDraft.value);
syncBackendState();
}
function onTestModeChange(): void {
NotificationDebugService.setTestModeEnabled(testModeEnabled.value);
}
function onBypassAuthChange(): void {
NotificationDebugService.setBypassAuthEnabled(bypassAuthEnabled.value);
}
async function onRegisterToken(): Promise<void> {
await withBusy(async () => {
try {
await NotificationDebugService.registerTokenNow();
} catch {
// logged in panel
} finally {
fcmToken.value = NotificationDebugService.getFcmToken();
}
});
}
async function onUploadAlertAuthorization(): Promise<void> {
alertAuthorizationStatus.value = null;
await withBusy(async () => {
const result =
await NotificationDebugService.uploadAlertSearchAuthorization();
alertAuthorizationStatus.value = result.ok
? {
ok: true,
message: [
`Uploaded ${result.jwtCount} JWTs (HTTP ${result.status}).`,
`Batch ${result.batchId}.`,
`${result.timezone}: ${result.firstDay} through ${result.lastDay}.`,
result.message,
]
.filter(Boolean)
.join(" "),
}
: {
ok: false,
message: [
`AlertSearch authorization upload failed: ${result.errorMessage}`,
result.status != null ? `(HTTP ${result.status})` : undefined,
result.errorCode ? `Code: ${result.errorCode}.` : undefined,
]
.filter(Boolean)
.join(" "),
};
});
}
async function onBackendRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.triggerBackendRefresh();
await refreshPending();
});
}
async function onSimulateWakeupRefresh(): Promise<void> {
await withBusy(async () => {
await NotificationDebugService.simulateWakeupViaRefresh();
await refreshPending();
});
}
function formatRealWakeupStatusMessage(
result: Awaited<
ReturnType<typeof NotificationDebugService.sendRealWakeupPing>
>,
): string {
if (result.ok) {
const parts = ["Real WAKEUP_PING sent via backend."];
const suffix = result.responseBody?.fcmTokenSuffix?.trim();
if (suffix) {
parts.push(`token …${suffix}`);
}
return parts.join(" ");
}
const parts = [`Real WAKEUP_PING failed: ${result.errorMessage}`];
if (result.status != null) {
parts.push(`(HTTP ${result.status})`);
}
return parts.join(" ");
}
async function onSendRealWakeupPing(): Promise<void> {
realWakeupStatus.value = null;
await withBusy(async () => {
const result = await NotificationDebugService.sendRealWakeupPing();
realWakeupStatus.value = {
ok: result.ok,
message: formatRealWakeupStatusMessage(result),
};
});
}
async function onCopyFcmToken(): Promise<void> {
const token = fcmToken.value?.trim();
if (!token) {
return;
}
await copyToClipboard(token);
}
onMounted(() => {
unsubscribeEventLog = subscribe((entries) => {
eventLog.value = [...entries];
});
syncBackendState();
void refreshPending();
});
onBeforeUnmount(() => {
unsubscribeEventLog?.();
});
</script>