@gscdump/nuxt 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/app/assets/css/main.css +2 -0
- package/app/components/GscAnalyzerPanel.vue +94 -0
- package/app/components/GscAnonymizationBanner.vue +46 -0
- package/app/components/GscBootProgress.vue +297 -0
- package/app/components/GscCommandPalette.vue +77 -0
- package/app/components/GscDashboardPage.vue +26 -0
- package/app/components/GscEngineBadge.vue +28 -0
- package/app/components/GscHero.vue +215 -0
- package/app/components/GscLazyTopResult.vue +45 -0
- package/app/components/GscPerformanceChart.vue +532 -0
- package/app/components/GscPositionDistributionChart.vue +63 -0
- package/app/components/GscQueryLabel.vue +63 -0
- package/app/components/GscSitePageHeader.vue +40 -0
- package/app/components/GscSourceDebugPanel.vue +195 -0
- package/app/components/GscSyncStatusCell.vue +54 -0
- package/app/components/GscTopRollupCard.vue +90 -0
- package/app/composables/_useGscBackfill.ts +111 -0
- package/app/composables/_useGscQueryDispatcher.ts +122 -0
- package/app/composables/_useGscResource.ts +114 -0
- package/app/composables/_useGscSharedSiteResource.ts +136 -0
- package/app/composables/useGscAnalytics.ts +197 -0
- package/app/composables/useGscAnalyticsClient.ts +9 -0
- package/app/composables/useGscAnalyticsConfig.ts +8 -0
- package/app/composables/useGscAnalyticsSourceInfo.ts +143 -0
- package/app/composables/useGscAnalyzer.ts +374 -0
- package/app/composables/useGscAnalyzerBatch.ts +106 -0
- package/app/composables/useGscAnalyzerDefs.ts +118 -0
- package/app/composables/useGscAuth.ts +115 -0
- package/app/composables/useGscConsoleUrl.ts +45 -0
- package/app/composables/useGscCountries.ts +47 -0
- package/app/composables/useGscCurrentSite.ts +15 -0
- package/app/composables/useGscEngine.ts +16 -0
- package/app/composables/useGscInspectionHistory.ts +42 -0
- package/app/composables/useGscInspections.ts +52 -0
- package/app/composables/useGscPanelContext.ts +24 -0
- package/app/composables/useGscParquetTable.ts +199 -0
- package/app/composables/useGscPeriod.ts +243 -0
- package/app/composables/useGscQuery.ts +290 -0
- package/app/composables/useGscQueryDispatcher.ts +122 -0
- package/app/composables/useGscRequestIndexingInspect.ts +20 -0
- package/app/composables/useGscResource.ts +114 -0
- package/app/composables/useGscRollup.ts +252 -0
- package/app/composables/useGscRollupTable.ts +78 -0
- package/app/composables/useGscRowQuery.ts +56 -0
- package/app/composables/useGscSearchAppearance.ts +47 -0
- package/app/composables/useGscSitemapHistory.ts +41 -0
- package/app/composables/useGscSitemaps.ts +45 -0
- package/app/composables/useGscTableState.ts +119 -0
- package/app/plugins/analytics.ts +57 -0
- package/app/utils/anonymization.ts +24 -0
- package/app/utils/country-names.ts +56 -0
- package/app/utils/gsc-constants.ts +10 -0
- package/app/utils/gsc-error.ts +58 -0
- package/app/utils/gsc-fetch.ts +94 -0
- package/app/utils/gsc-filters.ts +32 -0
- package/app/utils/gsc-rows.ts +72 -0
- package/app/utils/position.ts +7 -0
- package/app/utils/setup-gsc-fetch-auth.ts +62 -0
- package/module.ts +81 -0
- package/nuxt.config.ts +36 -0
- package/package.json +75 -0
- package/types.ts +114 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Floating debug panel showing which AnalysisQuerySource the server resolved
|
|
3
|
+
// for the current site. Reads `/api/__gsc/sites/:siteId/source-info`. Ships in the
|
|
4
|
+
// layer so every consumer gets the same shape without reimplementing.
|
|
5
|
+
//
|
|
6
|
+
// Siting:
|
|
7
|
+
// - `site` prop > `siteId` prop > route param `id` (the canonical path)
|
|
8
|
+
// - Off-site routes (overview, /pricing, etc.) render a "no site context" tile.
|
|
9
|
+
//
|
|
10
|
+
// Visibility:
|
|
11
|
+
// - Hidden by default; click the floating ⓘ pill to open.
|
|
12
|
+
// - Preference persists in localStorage per origin (key `gsc-debug-panel`).
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
/** Explicit site id. Falls back to route.params.id. */
|
|
16
|
+
site?: string
|
|
17
|
+
/** @deprecated Use `site`. Kept so older callers still type-check. */
|
|
18
|
+
siteId?: string
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const route = useRoute()
|
|
22
|
+
const resolvedSite = computed(() => props.site ?? props.siteId ?? (typeof route.params.id === 'string' ? route.params.id : null))
|
|
23
|
+
|
|
24
|
+
const open = useLocalStorage('gsc-debug-panel', false)
|
|
25
|
+
|
|
26
|
+
const { info, loading, error } = useGscAnalyticsSourceInfo(() => resolvedSite.value)
|
|
27
|
+
|
|
28
|
+
// Off-site fallback — `/api/__gsc/whoami` exposes identity.attrs (tier,
|
|
29
|
+
// plan, etc.) so the panel can still show viewer context on routes without
|
|
30
|
+
// a site (overview, pricing, landing pages). Only fetched when we have no
|
|
31
|
+
// site to resolve, so per-site routes pay nothing for this.
|
|
32
|
+
interface WhoamiResponse {
|
|
33
|
+
userId: string
|
|
34
|
+
siteIds: string[] | null
|
|
35
|
+
identityAttrs: Record<string, unknown>
|
|
36
|
+
sourceProviderRegistered: boolean
|
|
37
|
+
}
|
|
38
|
+
const whoami = ref<WhoamiResponse | null>(null)
|
|
39
|
+
const whoamiLoading = ref(false)
|
|
40
|
+
watchEffect(() => {
|
|
41
|
+
if (!import.meta.client || resolvedSite.value || !open.value || whoami.value || whoamiLoading.value)
|
|
42
|
+
return
|
|
43
|
+
whoamiLoading.value = true
|
|
44
|
+
const request = useGscAnalyticsClient().whoami() as Promise<WhoamiResponse>
|
|
45
|
+
request
|
|
46
|
+
.then((res) => { whoami.value = res })
|
|
47
|
+
.catch(() => { /* surface via empty state, not a toast */ })
|
|
48
|
+
.finally(() => { whoamiLoading.value = false })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function fmtKind(kind: 'row' | 'sql'): string {
|
|
52
|
+
return kind === 'sql' ? 'SQL' : 'Rows'
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<ClientOnly>
|
|
58
|
+
<!-- Toggle affordance: always visible, bottom-right. -->
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
class="fixed z-40 bottom-3 right-3 inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full border border-default bg-default/90 backdrop-blur text-[11px] font-mono text-muted hover:text-default shadow-sm transition-colors"
|
|
62
|
+
:title="open ? 'Hide data-source debug panel' : 'Show data-source debug panel'"
|
|
63
|
+
@click="open = !open"
|
|
64
|
+
>
|
|
65
|
+
<span class="size-1.5 rounded-full" :class="info ? (info.kind === 'sql' ? 'bg-primary' : 'bg-amber-500') : 'bg-dimmed'" />
|
|
66
|
+
src: {{ info?.name ?? (loading ? '…' : resolvedSite ? '—' : 'no site') }}
|
|
67
|
+
<UIcon :name="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="size-3" />
|
|
68
|
+
</button>
|
|
69
|
+
|
|
70
|
+
<!-- Panel body -->
|
|
71
|
+
<div
|
|
72
|
+
v-if="open"
|
|
73
|
+
class="fixed z-40 bottom-12 right-3 w-[320px] rounded-lg border border-default bg-default/95 backdrop-blur shadow-lg text-[12px] font-mono"
|
|
74
|
+
>
|
|
75
|
+
<header class="flex items-center justify-between px-3 py-2 border-b border-default">
|
|
76
|
+
<span class="font-semibold text-default">Analytics source</span>
|
|
77
|
+
<button type="button" class="text-dimmed hover:text-default" @click="open = false">
|
|
78
|
+
<UIcon name="i-lucide-x" class="size-3.5" />
|
|
79
|
+
</button>
|
|
80
|
+
</header>
|
|
81
|
+
|
|
82
|
+
<div v-if="!resolvedSite" class="flex flex-col divide-y divide-default">
|
|
83
|
+
<div class="px-3 py-2.5 text-dimmed text-[11px] leading-snug">
|
|
84
|
+
No site context on this route. Showing identity only.
|
|
85
|
+
</div>
|
|
86
|
+
<div v-if="whoamiLoading && !whoami" class="p-3 text-dimmed">
|
|
87
|
+
Resolving…
|
|
88
|
+
</div>
|
|
89
|
+
<div v-else-if="whoami" class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2.5">
|
|
90
|
+
<span class="text-dimmed">userId</span>
|
|
91
|
+
<span class="text-default truncate" :title="whoami.userId">{{ whoami.userId }}</span>
|
|
92
|
+
|
|
93
|
+
<span class="text-dimmed">sites</span>
|
|
94
|
+
<span class="text-default">
|
|
95
|
+
{{ whoami.siteIds == null ? 'all' : `${whoami.siteIds.length} scoped` }}
|
|
96
|
+
</span>
|
|
97
|
+
|
|
98
|
+
<span class="text-dimmed">source</span>
|
|
99
|
+
<span :class="whoami.sourceProviderRegistered ? 'text-success' : 'text-error'">
|
|
100
|
+
{{ whoami.sourceProviderRegistered ? 'registered' : 'not registered' }}
|
|
101
|
+
</span>
|
|
102
|
+
|
|
103
|
+
<template v-for="(value, key) in whoami.identityAttrs" :key="key">
|
|
104
|
+
<span class="text-dimmed">{{ key }}</span>
|
|
105
|
+
<span class="text-default truncate" :title="String(value)">{{ String(value) }}</span>
|
|
106
|
+
</template>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div v-else-if="loading && !info" class="p-3 text-dimmed">
|
|
110
|
+
Resolving…
|
|
111
|
+
</div>
|
|
112
|
+
<div v-else-if="error" class="p-3 text-error">
|
|
113
|
+
<div class="font-semibold mb-1">
|
|
114
|
+
Failed to resolve source
|
|
115
|
+
</div>
|
|
116
|
+
<div class="text-[11px] leading-snug">
|
|
117
|
+
{{ error.message }}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div v-else-if="info" class="flex flex-col divide-y divide-default">
|
|
121
|
+
<div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2.5">
|
|
122
|
+
<span class="text-dimmed">site</span>
|
|
123
|
+
<span class="truncate text-default" :title="resolvedSite">{{ resolvedSite }}</span>
|
|
124
|
+
|
|
125
|
+
<span class="text-dimmed">name</span>
|
|
126
|
+
<span class="text-default">{{ info.name }}</span>
|
|
127
|
+
|
|
128
|
+
<span class="text-dimmed">kind</span>
|
|
129
|
+
<span>
|
|
130
|
+
<span
|
|
131
|
+
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wider font-semibold"
|
|
132
|
+
:class="info.kind === 'sql' ? 'bg-primary/10 text-primary' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'"
|
|
133
|
+
>{{ fmtKind(info.kind) }}</span>
|
|
134
|
+
</span>
|
|
135
|
+
|
|
136
|
+
<span class="text-dimmed">attach</span>
|
|
137
|
+
<span :class="info.browserAttachEligible ? 'text-success' : 'text-muted'">
|
|
138
|
+
{{ info.browserAttachEligible ? 'browser-attached' : 'server-routed' }}
|
|
139
|
+
</span>
|
|
140
|
+
|
|
141
|
+
<span class="text-dimmed">analyzers</span>
|
|
142
|
+
<span class="text-default">
|
|
143
|
+
{{ info.supportedAnalyzerIds.length }}
|
|
144
|
+
<span class="text-dimmed">runnable</span>
|
|
145
|
+
</span>
|
|
146
|
+
|
|
147
|
+
<template v-for="(value, key) in (info.identityAttrs ?? {})" :key="key">
|
|
148
|
+
<span class="text-dimmed">{{ key }}</span>
|
|
149
|
+
<span class="text-default truncate" :title="String(value)">{{ String(value) }}</span>
|
|
150
|
+
</template>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div class="px-3 py-2.5">
|
|
154
|
+
<div class="text-[10px] uppercase tracking-widest text-dimmed font-semibold mb-1.5">
|
|
155
|
+
capabilities
|
|
156
|
+
</div>
|
|
157
|
+
<div class="flex flex-wrap gap-1">
|
|
158
|
+
<template v-for="(value, cap) in info.capabilities" :key="cap">
|
|
159
|
+
<span
|
|
160
|
+
v-if="value"
|
|
161
|
+
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-elevated text-muted border border-default"
|
|
162
|
+
>
|
|
163
|
+
{{ cap }}
|
|
164
|
+
</span>
|
|
165
|
+
</template>
|
|
166
|
+
<span
|
|
167
|
+
v-if="!Object.values(info.capabilities).some(Boolean)"
|
|
168
|
+
class="text-[11px] text-dimmed"
|
|
169
|
+
>
|
|
170
|
+
none advertised
|
|
171
|
+
</span>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<details class="px-3 py-2.5">
|
|
176
|
+
<summary class="text-[10px] uppercase tracking-widest text-dimmed font-semibold cursor-pointer">
|
|
177
|
+
supported analyzer ids ({{ info.supportedAnalyzerIds.length }})
|
|
178
|
+
</summary>
|
|
179
|
+
<div class="mt-2 max-h-48 overflow-y-auto">
|
|
180
|
+
<div
|
|
181
|
+
v-for="id in info.supportedAnalyzerIds"
|
|
182
|
+
:key="id"
|
|
183
|
+
class="text-[11px] text-muted py-0.5"
|
|
184
|
+
>
|
|
185
|
+
{{ id }}
|
|
186
|
+
</div>
|
|
187
|
+
<div v-if="!info.supportedAnalyzerIds.length" class="text-[11px] text-dimmed">
|
|
188
|
+
none
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</details>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</ClientOnly>
|
|
195
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{
|
|
3
|
+
data?: {
|
|
4
|
+
status: string
|
|
5
|
+
rowsFetched: number
|
|
6
|
+
rowsInserted: number
|
|
7
|
+
error: string | null
|
|
8
|
+
}
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const statusColor = computed(() => {
|
|
12
|
+
if (!props.data)
|
|
13
|
+
return 'text-muted'
|
|
14
|
+
switch (props.data.status) {
|
|
15
|
+
case 'completed': return 'text-success'
|
|
16
|
+
case 'processing': return 'text-info'
|
|
17
|
+
case 'queued': return 'text-muted'
|
|
18
|
+
case 'failed': return 'text-error'
|
|
19
|
+
default: return 'text-muted'
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const statusIcon = computed(() => {
|
|
24
|
+
if (!props.data)
|
|
25
|
+
return '-'
|
|
26
|
+
switch (props.data.status) {
|
|
27
|
+
case 'completed': return '✓'
|
|
28
|
+
case 'processing': return '⟳'
|
|
29
|
+
case 'queued': return '○'
|
|
30
|
+
case 'failed': return '✗'
|
|
31
|
+
default: return '?'
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<span v-if="!data" class="text-muted">-</span>
|
|
38
|
+
<UTooltip v-else>
|
|
39
|
+
<span :class="statusColor" class="font-mono">
|
|
40
|
+
{{ statusIcon }}
|
|
41
|
+
<span v-if="data.status === 'completed'" class="text-muted ml-1">{{ data.rowsFetched }}</span>
|
|
42
|
+
</span>
|
|
43
|
+
<template #content>
|
|
44
|
+
<div class="text-xs space-y-1">
|
|
45
|
+
<div>Status: {{ data.status }}</div>
|
|
46
|
+
<div>Fetched: {{ data.rowsFetched?.toLocaleString() || 0 }}</div>
|
|
47
|
+
<div>Inserted: {{ data.rowsInserted?.toLocaleString() || 0 }}</div>
|
|
48
|
+
<div v-if="data.error" class="text-error">
|
|
49
|
+
Error: {{ data.error }}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</template>
|
|
53
|
+
</UTooltip>
|
|
54
|
+
</template>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Reads a `top_*_28d` rollup directly from R2 and renders the top 10 rows.
|
|
3
|
+
// Zero DuckDB round-trip — the rollup pre-aggregated clicks over the
|
|
4
|
+
// trailing 28 days when sync last completed. Hidden when the rollup isn't
|
|
5
|
+
// built yet (dashboard still shows the live tables as the canonical view).
|
|
6
|
+
|
|
7
|
+
import { useGscRollup } from '../composables/useGscRollup'
|
|
8
|
+
|
|
9
|
+
interface TopPageRow { url: string, clicks: number, impressions: number, sum_position: number }
|
|
10
|
+
interface TopKeywordRow { query: string, clicks: number, impressions: number, sum_position: number }
|
|
11
|
+
type TopRow = TopPageRow | TopKeywordRow
|
|
12
|
+
|
|
13
|
+
const { siteIdentifier, rollupId, title, labelKey, hrefBase } = defineProps<{
|
|
14
|
+
siteIdentifier: string | null | undefined
|
|
15
|
+
rollupId: 'top_pages_28d' | 'top_keywords_28d'
|
|
16
|
+
title: string
|
|
17
|
+
// Field on each row to render as the label (e.g. 'url' or 'query')
|
|
18
|
+
labelKey: 'url' | 'query'
|
|
19
|
+
// Optional: if set, label becomes a link to `${hrefBase}${encodeURIComponent(label)}`
|
|
20
|
+
hrefBase?: string
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const { envelope, loading } = useGscRollup<TopRow[]>(() => siteIdentifier ?? null, () => rollupId)
|
|
24
|
+
|
|
25
|
+
const top10 = computed(() => (envelope.value?.payload ?? []).slice(0, 10))
|
|
26
|
+
const hasClicks = computed(() => top10.value.some((r: TopRow) => r.clicks > 0))
|
|
27
|
+
|
|
28
|
+
const builtAtRelative = computed(() => {
|
|
29
|
+
const builtAt = envelope.value?.builtAt
|
|
30
|
+
if (!builtAt)
|
|
31
|
+
return null
|
|
32
|
+
const ageMs = Date.now() - builtAt
|
|
33
|
+
const hours = Math.floor(ageMs / 3600_000)
|
|
34
|
+
if (hours < 1)
|
|
35
|
+
return 'just now'
|
|
36
|
+
if (hours < 24)
|
|
37
|
+
return `${hours}h ago`
|
|
38
|
+
return `${Math.floor(hours / 24)}d ago`
|
|
39
|
+
})
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<UCard v-if="loading || top10.length">
|
|
44
|
+
<template #header>
|
|
45
|
+
<div class="flex items-center justify-between gap-4">
|
|
46
|
+
<div>
|
|
47
|
+
<h3 class="font-semibold">
|
|
48
|
+
{{ title }}
|
|
49
|
+
</h3>
|
|
50
|
+
<p class="text-xs text-muted mt-0.5">
|
|
51
|
+
Last 28 days<span v-if="builtAtRelative"> · built {{ builtAtRelative }}</span>
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
<span class="text-[10px] font-mono uppercase text-cyan-400 border border-cyan-500/40 bg-cyan-500/5 px-1.5 py-0.5">
|
|
55
|
+
rollup
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<div v-if="loading && !top10.length" class="space-y-2">
|
|
61
|
+
<USkeleton v-for="i in 5" :key="i" class="h-6" />
|
|
62
|
+
</div>
|
|
63
|
+
<div v-else-if="!hasClicks" class="text-sm text-muted py-4 text-center">
|
|
64
|
+
No clicks in the last 28 days.
|
|
65
|
+
</div>
|
|
66
|
+
<div v-else class="space-y-1">
|
|
67
|
+
<div
|
|
68
|
+
v-for="(row, i) in top10"
|
|
69
|
+
:key="String((row as any)[labelKey])"
|
|
70
|
+
class="flex items-center justify-between gap-3 py-1 text-sm border-b border-default/40 last:border-0"
|
|
71
|
+
>
|
|
72
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
73
|
+
<span class="text-xs text-muted tabular-nums w-5 text-right">{{ i + 1 }}</span>
|
|
74
|
+
<component
|
|
75
|
+
:is="hrefBase ? 'NuxtLink' : 'span'"
|
|
76
|
+
:to="hrefBase ? `${hrefBase}${encodeURIComponent(String((row as any)[labelKey]))}` : undefined"
|
|
77
|
+
class="truncate"
|
|
78
|
+
:class="hrefBase ? 'text-primary hover:underline' : ''"
|
|
79
|
+
>
|
|
80
|
+
{{ (row as any)[labelKey] }}
|
|
81
|
+
</component>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="flex items-center gap-3 text-xs shrink-0 tabular-nums text-muted">
|
|
84
|
+
<span class="text-neutral-200">{{ row.clicks.toLocaleString() }}</span>
|
|
85
|
+
<span>{{ row.impressions.toLocaleString() }} impr</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</UCard>
|
|
90
|
+
</template>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Detects `meta.backfillRequired` from tool/analysis responses, kicks off an
|
|
2
|
+
// on-demand backfill request, polls sync-progress, and invokes a refetch once
|
|
3
|
+
// the requested range is synced.
|
|
4
|
+
|
|
5
|
+
import { useGscFetch } from '../utils/gsc-fetch'
|
|
6
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
7
|
+
|
|
8
|
+
interface BackfillRange {
|
|
9
|
+
startDate: string
|
|
10
|
+
endDate: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SyncProgressResponse {
|
|
14
|
+
sites?: Array<{
|
|
15
|
+
id: string
|
|
16
|
+
oldestDateSynced?: string | null
|
|
17
|
+
newestDateSynced?: string | null
|
|
18
|
+
syncStatus?: string
|
|
19
|
+
backfill?: { percent?: number }
|
|
20
|
+
}>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GscBackfillState {
|
|
24
|
+
pending: Ref<boolean>
|
|
25
|
+
range: Ref<BackfillRange | null>
|
|
26
|
+
percent: Ref<number>
|
|
27
|
+
error: Ref<string | null>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const POLL_INTERVAL_MS = 4000
|
|
31
|
+
const POLL_MAX_MS = 15 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
export function useGscBackfill(): GscBackfillState & {
|
|
34
|
+
request: (siteId: string, req: BackfillRange, refetch: () => Promise<unknown> | unknown) => Promise<void>
|
|
35
|
+
maybeTrigger: (meta: unknown, siteId: string, refetch: () => Promise<unknown> | unknown) => boolean
|
|
36
|
+
reset: () => void
|
|
37
|
+
} {
|
|
38
|
+
const pending = ref(false)
|
|
39
|
+
const range = ref<BackfillRange | null>(null)
|
|
40
|
+
const percent = ref(0)
|
|
41
|
+
const error = ref<string | null>(null)
|
|
42
|
+
// Track ranges we've already attempted in this session so a failed/timed-out
|
|
43
|
+
// backfill doesn't retrigger on every refetch.
|
|
44
|
+
const attemptedKeys = new Set<string>()
|
|
45
|
+
|
|
46
|
+
function rangeKey(siteId: string, r: BackfillRange): string {
|
|
47
|
+
return `${siteId}:${r.startDate}:${r.endDate}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function isRangeCovered(siteId: string, req: BackfillRange): Promise<boolean> {
|
|
51
|
+
const progress = await useGscFetch()<SyncProgressResponse>('/api/sync-progress').catch(() => null)
|
|
52
|
+
const site = progress?.sites?.find(s => s.id === siteId)
|
|
53
|
+
if (!site?.oldestDateSynced || !site?.newestDateSynced)
|
|
54
|
+
return false
|
|
55
|
+
percent.value = site.backfill?.percent ?? percent.value
|
|
56
|
+
return req.startDate >= site.oldestDateSynced && req.endDate <= site.newestDateSynced
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function request(siteId: string, req: BackfillRange, refetch: () => Promise<unknown> | unknown): Promise<void> {
|
|
60
|
+
pending.value = true
|
|
61
|
+
range.value = req
|
|
62
|
+
percent.value = 0
|
|
63
|
+
error.value = null
|
|
64
|
+
attemptedKeys.add(rangeKey(siteId, req))
|
|
65
|
+
|
|
66
|
+
await useGscAnalyticsClient().requestBackfill(siteId, req).catch((err) => {
|
|
67
|
+
error.value = (err as { data?: { message?: string }, message?: string })?.data?.message
|
|
68
|
+
|| (err as Error)?.message
|
|
69
|
+
|| 'Failed to queue backfill'
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (error.value) {
|
|
73
|
+
pending.value = false
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const started = Date.now()
|
|
78
|
+
let covered = false
|
|
79
|
+
while (Date.now() - started < POLL_MAX_MS) {
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
81
|
+
covered = await isRangeCovered(siteId, req)
|
|
82
|
+
if (covered)
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!covered)
|
|
87
|
+
error.value = 'Backfill did not complete within the expected window. Try again later.'
|
|
88
|
+
|
|
89
|
+
if (covered)
|
|
90
|
+
await refetch()
|
|
91
|
+
pending.value = false
|
|
92
|
+
range.value = null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function maybeTrigger(meta: unknown, siteId: string, refetch: () => Promise<unknown> | unknown): boolean {
|
|
96
|
+
const backfillRequired = (meta as { backfillRequired?: BackfillRange })?.backfillRequired
|
|
97
|
+
if (!backfillRequired || pending.value)
|
|
98
|
+
return false
|
|
99
|
+
if (attemptedKeys.has(rangeKey(siteId, backfillRequired)))
|
|
100
|
+
return false
|
|
101
|
+
request(siteId, backfillRequired, refetch)
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function reset(): void {
|
|
106
|
+
attemptedKeys.clear()
|
|
107
|
+
error.value = null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { pending, range, percent, error, request, maybeTrigger, reset }
|
|
111
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Per-query dispatch policy + fallback telemetry behind one swappable seam.
|
|
2
|
+
//
|
|
3
|
+
// `useGscQuery` owns *intent* (the caller's `engine: 'auto' | 'browser' | 'server'`);
|
|
4
|
+
// the dispatcher owns *policy* (how `auto` resolves against current auth, what
|
|
5
|
+
// to do with auto-fallback events). Default impl is no-op telemetry — hosts
|
|
6
|
+
// that want fallback beacons override the provide with a configured factory.
|
|
7
|
+
|
|
8
|
+
import type { _useGscAuthInternal } from './useGscAuth'
|
|
9
|
+
import type { GscQueryDecisionReason, GscQueryEngine } from './useGscQuery'
|
|
10
|
+
import { useGscEngine } from './useGscEngine'
|
|
11
|
+
|
|
12
|
+
type InternalAuthState = ReturnType<typeof _useGscAuthInternal>['value']
|
|
13
|
+
|
|
14
|
+
export interface GscEngineDecision {
|
|
15
|
+
mode: 'browser' | 'server'
|
|
16
|
+
reason: GscQueryDecisionReason
|
|
17
|
+
/** The resolved intent before the auth/optin mapping — useful for callers that branch on "did the user ask for auto?". */
|
|
18
|
+
requested: GscQueryEngine
|
|
19
|
+
detail?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GscFallbackEvent {
|
|
23
|
+
reason: string
|
|
24
|
+
at: number
|
|
25
|
+
url: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PickEngineOpts {
|
|
29
|
+
/** Per-call override. Wins over `useGscEngine()` + `runtimeConfig.public.analytics.defaultEngine`. */
|
|
30
|
+
perCall?: GscQueryEngine
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GscQueryDispatcher {
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the active engine into a concrete mode + reason. Consults the
|
|
36
|
+
* resolution chain: `opts.perCall` → `useGscEngine()` → runtimeConfig →
|
|
37
|
+
* `'auto'`. Then maps `auto` against `auth.browserAnalyzerEnabled`.
|
|
38
|
+
*/
|
|
39
|
+
pickEngine: (auth: InternalAuthState, opts?: PickEngineOpts) => GscEngineDecision
|
|
40
|
+
/** Called once per auto-fallback. Default impl is a no-op. */
|
|
41
|
+
reportFallback: (event: GscFallbackEvent) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CreateDefaultDispatcherOpts {
|
|
45
|
+
/** When set, fallbacks beacon to this URL with batched payloads. */
|
|
46
|
+
telemetryEndpoint?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FLUSH_DELAY_MS = 5000
|
|
50
|
+
|
|
51
|
+
function createReporter(endpoint: string): (event: GscFallbackEvent) => void {
|
|
52
|
+
const buffer: GscFallbackEvent[] = []
|
|
53
|
+
let flushScheduled = false
|
|
54
|
+
|
|
55
|
+
function flush(): void {
|
|
56
|
+
flushScheduled = false
|
|
57
|
+
if (buffer.length === 0)
|
|
58
|
+
return
|
|
59
|
+
const events = buffer.splice(0)
|
|
60
|
+
const body = JSON.stringify({ events })
|
|
61
|
+
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
62
|
+
navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
fetch(endpoint, { method: 'POST', body, headers: { 'content-type': 'application/json' } }).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function scheduleFlush(): void {
|
|
69
|
+
if (flushScheduled || typeof window === 'undefined')
|
|
70
|
+
return
|
|
71
|
+
flushScheduled = true
|
|
72
|
+
setTimeout(flush, FLUSH_DELAY_MS)
|
|
73
|
+
if (typeof document !== 'undefined') {
|
|
74
|
+
const handler = (): void => {
|
|
75
|
+
if (document.visibilityState === 'hidden')
|
|
76
|
+
flush()
|
|
77
|
+
}
|
|
78
|
+
document.addEventListener('visibilitychange', handler, { once: true })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (event: GscFallbackEvent): void => {
|
|
83
|
+
buffer.push(event)
|
|
84
|
+
scheduleFlush()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createDefaultGscQueryDispatcher(
|
|
89
|
+
opts: CreateDefaultDispatcherOpts = {},
|
|
90
|
+
): GscQueryDispatcher {
|
|
91
|
+
const reportFallback = opts.telemetryEndpoint
|
|
92
|
+
? createReporter(opts.telemetryEndpoint)
|
|
93
|
+
: (): void => {}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
pickEngine(auth, opts = {}): GscEngineDecision {
|
|
97
|
+
const requested = opts.perCall ?? resolveDefaultEngine()
|
|
98
|
+
if (requested === 'server')
|
|
99
|
+
return { mode: 'server', reason: 'forced:server', requested }
|
|
100
|
+
if (requested === 'browser')
|
|
101
|
+
return { mode: 'browser', reason: 'forced:browser', requested }
|
|
102
|
+
// 'auto': when the host has wired setGscAuth, derive from the per-user
|
|
103
|
+
// browserAnalyzerEnabled flag. When unwired, preserve legacy 'auto' = browser.
|
|
104
|
+
if (auth._initialized && !auth.browserAnalyzerEnabled)
|
|
105
|
+
return { mode: 'server', reason: 'optin:off', requested }
|
|
106
|
+
return { mode: 'browser', reason: 'auto:browser', requested }
|
|
107
|
+
},
|
|
108
|
+
reportFallback,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveDefaultEngine(): GscQueryEngine {
|
|
113
|
+
const override = useGscEngine().value
|
|
114
|
+
if (override)
|
|
115
|
+
return override
|
|
116
|
+
const cfg = useRuntimeConfig().public.analytics as { defaultEngine?: GscQueryEngine } | undefined
|
|
117
|
+
return cfg?.defaultEngine ?? 'auto'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function useGscQueryDispatcher(): GscQueryDispatcher {
|
|
121
|
+
return useNuxtApp().$gscQueryDispatcher
|
|
122
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Site-keyed async resource composable. Owns key-watch, stale-token discard,
|
|
2
|
+
// status classification, refresh, and dispose for every read-only `/api/__gsc/*`
|
|
3
|
+
// fetcher in the layer. Resource composables (useGscSitemaps,
|
|
4
|
+
// useGscIndexingDiagnostics, …) are thin adapters that wire reactive keys to
|
|
5
|
+
// `useGscAnalyticsClient().getX(...)` plus optional derived computeds.
|
|
6
|
+
//
|
|
7
|
+
// Stale-token (not AbortController) because `@gscdump/sdk`'s AnalyticsClient
|
|
8
|
+
// doesn't accept a signal. Late-arriving promises from a superseded run are
|
|
9
|
+
// dropped on `data`/`status` writes; the in-flight fetch still runs to
|
|
10
|
+
// completion, which is acceptable for read-only GETs.
|
|
11
|
+
|
|
12
|
+
import type { ComputedRef, Ref, WatchSource } from '@vue/runtime-core'
|
|
13
|
+
import type { GscErrorStatus } from '../utils/gsc-error'
|
|
14
|
+
import { classifyGscError } from '../utils/gsc-error'
|
|
15
|
+
|
|
16
|
+
export type GscResourceStatus = 'idle' | 'pending' | 'success' | 'empty' | GscErrorStatus
|
|
17
|
+
|
|
18
|
+
export interface UseGscResourceOptions<TArgs extends readonly unknown[], TData> {
|
|
19
|
+
/** Reactive args passed to the fetcher. Resource stays idle while any required key is null/undefined. */
|
|
20
|
+
keys: { [I in keyof TArgs]: MaybeRefOrGetter<TArgs[I] | null | undefined> }
|
|
21
|
+
/** Async fetcher invoked with the resolved keys. */
|
|
22
|
+
fetcher: (...args: TArgs) => Promise<TData | null>
|
|
23
|
+
/** Predicate for the `empty` status — defaults to checking `null`/`[]`/typical container fields. */
|
|
24
|
+
isEmpty?: (data: TData) => boolean
|
|
25
|
+
/** Extra reactive sources that should retrigger a fetch. */
|
|
26
|
+
watchSources?: WatchSource[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseGscResourceReturn<TData> {
|
|
30
|
+
data: Ref<TData | null>
|
|
31
|
+
status: Ref<GscResourceStatus>
|
|
32
|
+
loading: ComputedRef<boolean>
|
|
33
|
+
error: Ref<Error | null>
|
|
34
|
+
refresh: () => Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultIsEmpty(v: unknown): boolean {
|
|
38
|
+
if (v == null)
|
|
39
|
+
return true
|
|
40
|
+
if (Array.isArray(v))
|
|
41
|
+
return v.length === 0
|
|
42
|
+
if (typeof v === 'object') {
|
|
43
|
+
const rec = v as Record<string, unknown>
|
|
44
|
+
for (const key of ['rows', 'records', 'snapshots', 'items', 'results']) {
|
|
45
|
+
const arr = rec[key]
|
|
46
|
+
if (Array.isArray(arr))
|
|
47
|
+
return arr.length === 0
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useGscResource<TArgs extends readonly unknown[], TData>(
|
|
54
|
+
opts: UseGscResourceOptions<TArgs, TData>,
|
|
55
|
+
): UseGscResourceReturn<TData> {
|
|
56
|
+
const data = shallowRef<TData | null>(null)
|
|
57
|
+
const status = ref<GscResourceStatus>('idle')
|
|
58
|
+
const error = ref<Error | null>(null)
|
|
59
|
+
const loading = computed(() => status.value === 'pending')
|
|
60
|
+
|
|
61
|
+
let runToken = 0
|
|
62
|
+
|
|
63
|
+
function resolveKeys(): TArgs | null {
|
|
64
|
+
const out: unknown[] = []
|
|
65
|
+
for (const k of opts.keys) {
|
|
66
|
+
const v = toValue(k)
|
|
67
|
+
if (v == null || v === '')
|
|
68
|
+
return null
|
|
69
|
+
out.push(v)
|
|
70
|
+
}
|
|
71
|
+
return out as unknown as TArgs
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function refresh(): Promise<void> {
|
|
75
|
+
const args = resolveKeys()
|
|
76
|
+
const token = ++runToken
|
|
77
|
+
if (!args) {
|
|
78
|
+
data.value = null
|
|
79
|
+
status.value = 'idle'
|
|
80
|
+
error.value = null
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
status.value = 'pending'
|
|
84
|
+
error.value = null
|
|
85
|
+
try {
|
|
86
|
+
const out = await opts.fetcher(...args)
|
|
87
|
+
if (token !== runToken)
|
|
88
|
+
return
|
|
89
|
+
data.value = out
|
|
90
|
+
const empty = out == null || (opts.isEmpty ?? defaultIsEmpty)(out)
|
|
91
|
+
status.value = empty ? 'empty' : 'success'
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (token !== runToken)
|
|
95
|
+
return
|
|
96
|
+
const classified = classifyGscError(e)
|
|
97
|
+
error.value = e instanceof Error ? e : new Error(String(e))
|
|
98
|
+
status.value = classified.status
|
|
99
|
+
data.value = null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
watch(
|
|
104
|
+
[...opts.keys.map(k => () => toValue(k)), ...(opts.watchSources ?? [])],
|
|
105
|
+
refresh,
|
|
106
|
+
{ immediate: true },
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
onScopeDispose(() => {
|
|
110
|
+
runToken++
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return { data, status, loading, error, refresh }
|
|
114
|
+
}
|