583 lines
19 KiB
Vue
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>
|