@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,374 @@
|
|
|
1
|
+
// Per-site analyzer. Two modes, picked from `/source-info`:
|
|
2
|
+
//
|
|
3
|
+
// - `browser-attached` — server source is SQL-capable and advertises
|
|
4
|
+
// `attachedTables`; we boot DuckDB-WASM in the browser and attach parquets
|
|
5
|
+
// for local querying. Fastest path for large dashboards.
|
|
6
|
+
// - `server` — everything else (GSC API live row source, engine sources
|
|
7
|
+
// without attach support, etc.). `analyze()` proxies to the server POST
|
|
8
|
+
// endpoint; `query()` throws since there's no local SQL engine.
|
|
9
|
+
//
|
|
10
|
+
// Cached per-site so navigation between panels on the same site doesn't
|
|
11
|
+
// re-boot. Writes progress to the shared map so <GscBootProgress> lights up
|
|
12
|
+
// on boot regardless of mode.
|
|
13
|
+
|
|
14
|
+
import type { AnalysisParams, AnalysisResult } from '@gscdump/analysis'
|
|
15
|
+
import type { AnalysisSourcesResponse, SourceInfoResponse } from '@gscdump/contracts'
|
|
16
|
+
import type { AttachedTablesHandle, BrowserAnalysisRuntime, DuckDBWasmBootResult, QueryResult } from '@gscdump/engine-duckdb-wasm'
|
|
17
|
+
import type { SiteLoadProgress } from './useGscAnalytics'
|
|
18
|
+
import { defaultAnalyzerRegistry } from '@gscdump/analysis'
|
|
19
|
+
import { coerceRow } from '@gscdump/engine'
|
|
20
|
+
import { useGscSharedSiteResource } from './_useGscSharedSiteResource'
|
|
21
|
+
import { useGscAnalyticsContext } from './useGscAnalytics'
|
|
22
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
23
|
+
import { useGscAnalyticsConfig } from './useGscAnalyticsConfig'
|
|
24
|
+
import { loadSourceInfoFor } from './useGscAnalyticsSourceInfo'
|
|
25
|
+
import { resolveGscAuthHeaders } from './useGscAuth'
|
|
26
|
+
|
|
27
|
+
export interface GscAnalyzerTimings {
|
|
28
|
+
bootMs: number
|
|
29
|
+
manifestMs: number
|
|
30
|
+
attachMs: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GscAnalyzerInstance {
|
|
34
|
+
ready: Ref<boolean>
|
|
35
|
+
initializing: Ref<boolean>
|
|
36
|
+
error: Ref<Error | null>
|
|
37
|
+
attachedTables: Ref<string[]>
|
|
38
|
+
timings: Ref<GscAnalyzerTimings | null>
|
|
39
|
+
/** Server-reported manifest version of the currently-attached snapshot. */
|
|
40
|
+
manifestVersion: Ref<string | undefined>
|
|
41
|
+
query: (sql: string, params?: unknown[]) => Promise<QueryResult>
|
|
42
|
+
analyze: (params: AnalysisParams, opts?: { signal?: AbortSignal }) => Promise<AnalysisResult & { queryMs: number }>
|
|
43
|
+
/**
|
|
44
|
+
* Re-probe `analysis-sources`; if the manifest version changed since the
|
|
45
|
+
* last attach, detach views and re-attach against the fresher parquet
|
|
46
|
+
* partitions in place. No-op for `server`-mode analyzers and when the
|
|
47
|
+
* version matches. Resolves to true if the runtime re-attached.
|
|
48
|
+
*/
|
|
49
|
+
refresh: () => Promise<boolean>
|
|
50
|
+
dispose: () => Promise<void>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const EMPTY_TABLES: readonly string[] = Object.freeze([])
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get (or create) an analyzer for a site. Per-site cached across the app so
|
|
57
|
+
* pages sharing a site reuse the boot. Refcounted — auto-disposes when the
|
|
58
|
+
* last consumer unmounts. The returned refs are `computed` over the currently
|
|
59
|
+
* bound cached instance; switching `siteId` rebinds and the computeds track
|
|
60
|
+
* the new instance with no manual mirroring.
|
|
61
|
+
*/
|
|
62
|
+
export function useGscAnalyzer(siteId: MaybeRefOrGetter<string | null | undefined>): GscAnalyzerInstance & { currentSiteId: Ref<string | null> } {
|
|
63
|
+
const ctx = useGscAnalyticsContext()
|
|
64
|
+
const { bound, currentSiteId } = useGscSharedSiteResource<GscAnalyzerInstance>('analyzer', siteId, {
|
|
65
|
+
factory: id => createInstance(id, ctx.patchProgress, () => loadSourceInfoFor(id) as Promise<SourceInfoResponse>),
|
|
66
|
+
onDispose: inst => inst.dispose(),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Computed views over the bound instance. Reactive on both site switch
|
|
70
|
+
// (bound changes) and inner ref updates on the cached instance.
|
|
71
|
+
const ready = computed(() => bound.value?.ready.value ?? false) as unknown as Ref<boolean>
|
|
72
|
+
const initializing = computed(() => bound.value?.initializing.value ?? false) as unknown as Ref<boolean>
|
|
73
|
+
const error = computed(() => bound.value?.error.value ?? null) as unknown as Ref<Error | null>
|
|
74
|
+
const attachedTables = computed(() => bound.value?.attachedTables.value ?? (EMPTY_TABLES as string[])) as unknown as Ref<string[]>
|
|
75
|
+
const timings = computed(() => bound.value?.timings.value ?? null) as unknown as Ref<GscAnalyzerTimings | null>
|
|
76
|
+
const manifestVersion = computed(() => bound.value?.manifestVersion.value) as unknown as Ref<string | undefined>
|
|
77
|
+
|
|
78
|
+
async function query(sql: string, params?: unknown[]): Promise<QueryResult> {
|
|
79
|
+
const inst = bound.value
|
|
80
|
+
if (!inst)
|
|
81
|
+
throw new Error('useGscAnalyzer: no site bound')
|
|
82
|
+
return inst.query(sql, params)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function analyze(params: AnalysisParams, opts?: { signal?: AbortSignal }): Promise<AnalysisResult & { queryMs: number }> {
|
|
86
|
+
const inst = bound.value
|
|
87
|
+
if (!inst)
|
|
88
|
+
throw new Error('useGscAnalyzer: no site bound')
|
|
89
|
+
return inst.analyze(params, opts)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function refresh(): Promise<boolean> {
|
|
93
|
+
const inst = bound.value
|
|
94
|
+
if (!inst)
|
|
95
|
+
return false
|
|
96
|
+
return inst.refresh()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
ready,
|
|
101
|
+
initializing,
|
|
102
|
+
error,
|
|
103
|
+
attachedTables,
|
|
104
|
+
timings,
|
|
105
|
+
manifestVersion,
|
|
106
|
+
refresh,
|
|
107
|
+
currentSiteId,
|
|
108
|
+
query,
|
|
109
|
+
analyze,
|
|
110
|
+
// No-op: lifecycle is owned by the shared bag's onScopeDispose hook.
|
|
111
|
+
// Kept for API compat with consumers that opportunistically call dispose().
|
|
112
|
+
dispose: async (): Promise<void> => {},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createInstance(
|
|
117
|
+
siteId: string,
|
|
118
|
+
patchProgress: (id: string, p: Partial<SiteLoadProgress>) => void,
|
|
119
|
+
sourceInfoLoader: () => Promise<SourceInfoResponse>,
|
|
120
|
+
): GscAnalyzerInstance {
|
|
121
|
+
const ready = ref(false)
|
|
122
|
+
const initializing = ref(true)
|
|
123
|
+
const error = ref<Error | null>(null)
|
|
124
|
+
const attachedTables = ref<string[]>([])
|
|
125
|
+
const timings = ref<GscAnalyzerTimings | null>(null)
|
|
126
|
+
const manifestVersion = ref<string | undefined>(undefined)
|
|
127
|
+
|
|
128
|
+
function patch(p: Partial<SiteLoadProgress>): void {
|
|
129
|
+
patchProgress(siteId, { source: 'duckdb', ...p })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let runtime: BrowserAnalysisRuntime | null = null
|
|
133
|
+
let bootedDb: DuckDBWasmBootResult | null = null
|
|
134
|
+
let attachedHandle: AttachedTablesHandle | null = null
|
|
135
|
+
let mode: 'browser-attached' | 'server' = 'server'
|
|
136
|
+
const inFlight = new Map<string, Promise<AnalysisResult & { queryMs: number }>>()
|
|
137
|
+
|
|
138
|
+
// Cross-origin: if the host returns relative parquet URLs (`/api/r2-data/…`)
|
|
139
|
+
// and `apiBase` is set, prefix them so DuckDB-WASM hits the data origin
|
|
140
|
+
// rather than the consumer's own host. Same-origin / absolute URLs pass
|
|
141
|
+
// through unchanged.
|
|
142
|
+
function rewriteParquetUrl(url: string): string {
|
|
143
|
+
const apiBase = useGscAnalyticsConfig().apiBase
|
|
144
|
+
if (!apiBase || !url.startsWith('/'))
|
|
145
|
+
return url
|
|
146
|
+
return `${apiBase.replace(/\/+$/, '')}${url}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function attachFromSources(sources: AnalysisSourcesResponse): Promise<{ attached: number, total: number }> {
|
|
150
|
+
if (!bootedDb)
|
|
151
|
+
throw new Error('useGscAnalyzer: attachFromSources called before DuckDB boot')
|
|
152
|
+
|
|
153
|
+
const tables = Object.entries(sources.tables)
|
|
154
|
+
.filter(([, urls]) => Array.isArray(urls) && urls.length > 0)
|
|
155
|
+
.map(([table, urls]) => ({ table, urls: urls.map(rewriteParquetUrl) }))
|
|
156
|
+
|
|
157
|
+
const total = tables.reduce((n, t) => n + t.urls.length, 0)
|
|
158
|
+
patch({ stage: 'attach', filesTotal: total, filesAttached: 0 })
|
|
159
|
+
|
|
160
|
+
// Cross-origin parquet GETs need the host-supplied auth header (same one
|
|
161
|
+
// useGscFetch attaches to /api/__gsc/* calls). DuckDB-WASM runs raw fetch
|
|
162
|
+
// under the hood, so we pass the header through fetchInit. Cookies aren't
|
|
163
|
+
// useful here — the parquet origin (gscdump.com) and the host page sit in
|
|
164
|
+
// different session realms when the consumer mode is active.
|
|
165
|
+
const extraHeaders = resolveGscAuthHeaders()
|
|
166
|
+
const hasExtra = Object.keys(extraHeaders).length > 0
|
|
167
|
+
let attached = 0
|
|
168
|
+
const { attachParquetUrlTables } = await import('@gscdump/engine-duckdb-wasm')
|
|
169
|
+
const handle = await attachParquetUrlTables({
|
|
170
|
+
db: bootedDb.db,
|
|
171
|
+
conn: bootedDb.conn,
|
|
172
|
+
tables,
|
|
173
|
+
version: sources.manifestVersion,
|
|
174
|
+
fetchInit: hasExtra
|
|
175
|
+
? { credentials: 'omit', headers: extraHeaders }
|
|
176
|
+
: { credentials: 'same-origin' },
|
|
177
|
+
onFileAttached: () => {
|
|
178
|
+
attached++
|
|
179
|
+
patch({ filesAttached: attached })
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
attachedHandle = handle
|
|
183
|
+
// `handle.tables` reflects what *actually* attached — `attachParquetUrlTables`
|
|
184
|
+
// drops tables on fetch failure, so the requested list can over-report.
|
|
185
|
+
// Use the authoritative list so downstream pre-checks (e.g. the engine's
|
|
186
|
+
// AttachedTableMissingError fast-fail) get an accurate view.
|
|
187
|
+
attachedTables.value = handle.tables
|
|
188
|
+
manifestVersion.value = sources.manifestVersion
|
|
189
|
+
return { attached, total }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const boot = (async () => {
|
|
193
|
+
patch({ stage: 'manifest', startedAt: Date.now(), filesAttached: 0, filesTotal: 0, error: undefined, endedAt: undefined })
|
|
194
|
+
// Probe the server-resolved source first. Its kind + attachedTables bit
|
|
195
|
+
// decides whether we boot DuckDB-WASM (expensive) or proxy to the server.
|
|
196
|
+
const info = await sourceInfoLoader()
|
|
197
|
+
mode = info.browserAttachEligible ? 'browser-attached' : 'server'
|
|
198
|
+
|
|
199
|
+
if (mode === 'server') {
|
|
200
|
+
// No local runtime; analyze() posts to the server. Mark ready so the
|
|
201
|
+
// shared progress UI stops spinning.
|
|
202
|
+
timings.value = { bootMs: 0, manifestMs: 0, attachMs: 0 }
|
|
203
|
+
ready.value = true
|
|
204
|
+
patch({ stage: 'ready', endedAt: Date.now() })
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const cfg = useGscAnalyticsConfig().duckdbBundleBase
|
|
209
|
+
patch({ stage: 'wasm' })
|
|
210
|
+
const t0 = performance.now()
|
|
211
|
+
// Dynamic import so server/consumer-mode hosts (no browser SQL) never pull
|
|
212
|
+
// the wasm engine into their client bundle. The static type-only import at
|
|
213
|
+
// top of the file keeps the type signatures available without an emit.
|
|
214
|
+
const { bootDuckDBWasm, createBrowserAnalysisRuntime } = await import('@gscdump/engine-duckdb-wasm')
|
|
215
|
+
bootedDb = await bootDuckDBWasm(cfg
|
|
216
|
+
? {
|
|
217
|
+
bundles: {
|
|
218
|
+
mvp: { mainModule: `${cfg}/duckdb-mvp.wasm`, mainWorker: `${cfg}/duckdb-browser-mvp.worker.js` },
|
|
219
|
+
eh: { mainModule: `${cfg}/duckdb-eh.wasm`, mainWorker: `${cfg}/duckdb-browser-eh.worker.js` },
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
: undefined)
|
|
223
|
+
const bootMs = performance.now() - t0
|
|
224
|
+
|
|
225
|
+
patch({ stage: 'manifest' })
|
|
226
|
+
const t1 = performance.now()
|
|
227
|
+
const sources = await useGscAnalyticsClient().getAnalysisSources(siteId) as AnalysisSourcesResponse
|
|
228
|
+
const manifestMs = performance.now() - t1
|
|
229
|
+
|
|
230
|
+
const t2 = performance.now()
|
|
231
|
+
const { total } = await attachFromSources(sources)
|
|
232
|
+
const attachMs = performance.now() - t2
|
|
233
|
+
|
|
234
|
+
runtime = createBrowserAnalysisRuntime(bootedDb, { schema: 'main', attachedTables: attachedTables.value })
|
|
235
|
+
runtime.setVersion(sources.manifestVersion)
|
|
236
|
+
timings.value = { bootMs, manifestMs, attachMs }
|
|
237
|
+
ready.value = true
|
|
238
|
+
patch({ stage: 'ready', filesAttached: total, endedAt: Date.now() })
|
|
239
|
+
return runtime
|
|
240
|
+
})()
|
|
241
|
+
.catch((e) => {
|
|
242
|
+
const err = e instanceof Error ? e : new Error(String(e))
|
|
243
|
+
error.value = err
|
|
244
|
+
patch({ stage: 'error', error: err.message, endedAt: Date.now() })
|
|
245
|
+
throw err
|
|
246
|
+
})
|
|
247
|
+
.finally(() => {
|
|
248
|
+
initializing.value = false
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// Don't let the boot promise crash the runtime — error.value surfaces it.
|
|
252
|
+
boot.catch(() => {})
|
|
253
|
+
|
|
254
|
+
async function query(sql: string, params?: unknown[]): Promise<QueryResult> {
|
|
255
|
+
const rt = await boot
|
|
256
|
+
if (!rt)
|
|
257
|
+
throw new Error('useGscAnalyzer: query() requires a SQL-capable source with attachedTables; current source routes through the server')
|
|
258
|
+
return rt.query(sql, params)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function runServerAnalyze(params: AnalysisParams, _signal?: AbortSignal): Promise<AnalysisResult & { queryMs: number }> {
|
|
262
|
+
const out = await useGscAnalyticsClient().analyze<AnalysisResult & { queryMs?: number }>(siteId, params)
|
|
263
|
+
return {
|
|
264
|
+
results: coerceResults(out.results) as AnalysisResult['results'],
|
|
265
|
+
meta: out.meta as AnalysisResult['meta'],
|
|
266
|
+
queryMs: out.queryMs ?? 0,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function analyze(params: AnalysisParams, opts?: { signal?: AbortSignal }): Promise<AnalysisResult & { queryMs: number }> {
|
|
271
|
+
const rt = await boot
|
|
272
|
+
opts?.signal?.throwIfAborted?.()
|
|
273
|
+
|
|
274
|
+
const key = JSON.stringify(params)
|
|
275
|
+
const existing = inFlight.get(key)
|
|
276
|
+
if (existing)
|
|
277
|
+
return opts?.signal ? raceSignal(existing, opts.signal) : existing
|
|
278
|
+
|
|
279
|
+
const p = (async () => {
|
|
280
|
+
if (!rt)
|
|
281
|
+
return runServerAnalyze(params, opts?.signal)
|
|
282
|
+
const out = await rt.analyze(params as never, defaultAnalyzerRegistry)
|
|
283
|
+
opts?.signal?.throwIfAborted?.()
|
|
284
|
+
return {
|
|
285
|
+
results: coerceResults(out.results) as AnalysisResult['results'],
|
|
286
|
+
meta: out.meta as AnalysisResult['meta'],
|
|
287
|
+
queryMs: out.queryMs,
|
|
288
|
+
}
|
|
289
|
+
})()
|
|
290
|
+
inFlight.set(key, p)
|
|
291
|
+
p.finally(() => {
|
|
292
|
+
if (inFlight.get(key) === p)
|
|
293
|
+
inFlight.delete(key)
|
|
294
|
+
})
|
|
295
|
+
return opts?.signal ? raceSignal(p, opts.signal) : p
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function refresh(): Promise<boolean> {
|
|
299
|
+
await boot
|
|
300
|
+
if (mode !== 'browser-attached' || !runtime || !bootedDb)
|
|
301
|
+
return false
|
|
302
|
+
const sources = await useGscAnalyticsClient().getAnalysisSources(siteId) as AnalysisSourcesResponse
|
|
303
|
+
if (!runtime.isStale(sources.manifestVersion))
|
|
304
|
+
return false
|
|
305
|
+
// Drop the stale views before swapping in the new partitions. The runtime
|
|
306
|
+
// (db + conn) stays alive — we're swapping the data, not the engine.
|
|
307
|
+
await attachedHandle?.detach().catch((e) => {
|
|
308
|
+
console.warn('[analyzer] detach during refresh failed', e)
|
|
309
|
+
})
|
|
310
|
+
attachedHandle = null
|
|
311
|
+
await attachFromSources(sources)
|
|
312
|
+
runtime.setVersion(sources.manifestVersion)
|
|
313
|
+
runtime.setAttachedTables(attachedTables.value)
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function dispose(): Promise<void> {
|
|
318
|
+
await runtime?.close().catch((e) => {
|
|
319
|
+
console.error('[analyzer] runtime.close failed', e)
|
|
320
|
+
})
|
|
321
|
+
runtime = null
|
|
322
|
+
bootedDb = null
|
|
323
|
+
attachedHandle = null
|
|
324
|
+
ready.value = false
|
|
325
|
+
attachedTables.value = []
|
|
326
|
+
manifestVersion.value = undefined
|
|
327
|
+
inFlight.clear()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { ready, initializing, error, attachedTables, timings, manifestVersion, query, analyze, refresh, dispose }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function coerceResults(results: unknown): unknown {
|
|
334
|
+
if (!Array.isArray(results))
|
|
335
|
+
return results
|
|
336
|
+
let changed = false
|
|
337
|
+
const out: unknown[] = Array.from({ length: results.length })
|
|
338
|
+
for (let i = 0; i < results.length; i++) {
|
|
339
|
+
const r = results[i]
|
|
340
|
+
if (r && typeof r === 'object') {
|
|
341
|
+
const c = coerceRow(r as Record<string, unknown>)
|
|
342
|
+
if (c !== r)
|
|
343
|
+
changed = true
|
|
344
|
+
out[i] = c
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
out[i] = r
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return changed ? out : results
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function abortError(): DOMException {
|
|
354
|
+
return new DOMException('aborted', 'AbortError')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function raceSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
358
|
+
if (signal.aborted)
|
|
359
|
+
return Promise.reject(abortError())
|
|
360
|
+
return new Promise<T>((resolve, reject) => {
|
|
361
|
+
const onAbort = (): void => reject(abortError())
|
|
362
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
363
|
+
promise.then(
|
|
364
|
+
(v) => {
|
|
365
|
+
signal.removeEventListener('abort', onAbort)
|
|
366
|
+
resolve(v)
|
|
367
|
+
},
|
|
368
|
+
(e) => {
|
|
369
|
+
signal.removeEventListener('abort', onAbort)
|
|
370
|
+
reject(e)
|
|
371
|
+
},
|
|
372
|
+
)
|
|
373
|
+
})
|
|
374
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Run N analyzers in parallel against a single analyzer runner, with per-id
|
|
2
|
+
// status tracking, optional concurrency cap, and stale-token discard so
|
|
3
|
+
// late-completing tasks from a superseded `run()` don't overwrite fresher state.
|
|
4
|
+
//
|
|
5
|
+
// Caller owns: result shaping (summarize, scoring), the trigger watcher,
|
|
6
|
+
// and which ids to run.
|
|
7
|
+
|
|
8
|
+
import type { MaybeRefOrGetter, Ref } from '@vue/runtime-core'
|
|
9
|
+
|
|
10
|
+
export type GscAnalyzerBatchStatus = 'idle' | 'pending' | 'running' | 'done' | 'error' | 'skipped'
|
|
11
|
+
|
|
12
|
+
export interface GscAnalyzerBatchEntry<TResult> {
|
|
13
|
+
status: GscAnalyzerBatchStatus
|
|
14
|
+
result: TResult | null
|
|
15
|
+
error: Error | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GscAnalyzerBatchRunner {
|
|
19
|
+
analyze: (params: { type: string, dateStart?: string, dateEnd?: string }) => Promise<unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseGscAnalyzerBatchOptions {
|
|
23
|
+
/** Cap parallel analyzer runs. DuckDB-WASM is single-threaded; high parallelism just serializes behind one connection. Default 2. */
|
|
24
|
+
concurrency?: number
|
|
25
|
+
/** Drop ids before running (e.g. analyzers the current source can't serve). Returning `false` marks them `skipped`. */
|
|
26
|
+
filter?: (id: string) => boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseGscAnalyzerBatchReturn<TResult> {
|
|
30
|
+
states: Ref<Record<string, GscAnalyzerBatchEntry<TResult>>>
|
|
31
|
+
running: Ref<boolean>
|
|
32
|
+
run: () => Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useGscAnalyzerBatch<TResult = unknown>(
|
|
36
|
+
runner: GscAnalyzerBatchRunner,
|
|
37
|
+
ids: MaybeRefOrGetter<readonly string[]>,
|
|
38
|
+
dateRange: MaybeRefOrGetter<{ start: string, end: string }>,
|
|
39
|
+
opts: UseGscAnalyzerBatchOptions = {},
|
|
40
|
+
): UseGscAnalyzerBatchReturn<TResult> {
|
|
41
|
+
const states = ref<Record<string, GscAnalyzerBatchEntry<TResult>>>({}) as Ref<Record<string, GscAnalyzerBatchEntry<TResult>>>
|
|
42
|
+
const running = ref(false)
|
|
43
|
+
let token = 0
|
|
44
|
+
|
|
45
|
+
function reset(currentIds: readonly string[]): void {
|
|
46
|
+
const next: Record<string, GscAnalyzerBatchEntry<TResult>> = {}
|
|
47
|
+
for (const id of currentIds)
|
|
48
|
+
next[id] = { status: 'pending', result: null, error: null }
|
|
49
|
+
states.value = next
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function run(): Promise<void> {
|
|
53
|
+
const myToken = ++token
|
|
54
|
+
const currentIds = toValue(ids)
|
|
55
|
+
const range = toValue(dateRange)
|
|
56
|
+
running.value = true
|
|
57
|
+
reset(currentIds)
|
|
58
|
+
|
|
59
|
+
const filter = opts.filter
|
|
60
|
+
const concurrency = Math.max(1, opts.concurrency ?? 2)
|
|
61
|
+
|
|
62
|
+
const queue: string[] = []
|
|
63
|
+
for (const id of currentIds) {
|
|
64
|
+
if (filter && !filter(id))
|
|
65
|
+
states.value = { ...states.value, [id]: { status: 'skipped', result: null, error: null } }
|
|
66
|
+
else
|
|
67
|
+
queue.push(id)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runOne(id: string): Promise<void> {
|
|
71
|
+
if (token !== myToken)
|
|
72
|
+
return
|
|
73
|
+
states.value = { ...states.value, [id]: { status: 'running', result: null, error: null } }
|
|
74
|
+
try {
|
|
75
|
+
const result = await runner.analyze({ type: id, dateStart: range.start, dateEnd: range.end })
|
|
76
|
+
if (token !== myToken)
|
|
77
|
+
return
|
|
78
|
+
states.value = { ...states.value, [id]: { status: 'done', result: result as TResult, error: null } }
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (token !== myToken)
|
|
82
|
+
return
|
|
83
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
84
|
+
states.value = { ...states.value, [id]: { status: 'error', result: null, error } }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const workers = Array.from({ length: concurrency }, async () => {
|
|
89
|
+
while (queue.length) {
|
|
90
|
+
if (token !== myToken)
|
|
91
|
+
return
|
|
92
|
+
const id = queue.shift()
|
|
93
|
+
if (!id)
|
|
94
|
+
break
|
|
95
|
+
await runOne(id)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
await Promise.all(workers)
|
|
100
|
+
|
|
101
|
+
if (token === myToken)
|
|
102
|
+
running.value = false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { states, running, run }
|
|
106
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Registry of analyzer definitions. The layer owns the type shape and the
|
|
2
|
+
// reader; hosts provide the array via a Nuxt plugin (`$gscAnalyzers`) or via
|
|
3
|
+
// the module's `analyzers` option (which generates the plugin at build time).
|
|
4
|
+
//
|
|
5
|
+
// Callers:
|
|
6
|
+
// - `/analyze` page builds its tab list from `useGscAnalyzerDefs()` and
|
|
7
|
+
// renders each tab via `<GscAnalyzerPanel :def>` driven by `panel`.
|
|
8
|
+
// - `/insights` page filters via `useGscAnalyzerDefsWithCapability('insightCard')`.
|
|
9
|
+
// - `useActionPriority` filters via `useGscAnalyzerDefsWithCapability('actionPriority')`.
|
|
10
|
+
//
|
|
11
|
+
// Capability slots are typed; e.g. `actionPriority` only accepts the
|
|
12
|
+
// `ActionSource` union from `@gscdump/analysis`, so misspelt or unknown
|
|
13
|
+
// sources fail at typecheck instead of at runtime in the priority runner.
|
|
14
|
+
|
|
15
|
+
import type { ActionSource, AnalysisTool } from '@gscdump/analysis'
|
|
16
|
+
import type { Component } from '@vue/runtime-core'
|
|
17
|
+
|
|
18
|
+
export type GscAnalyzerKind = 'analyzer' | 'semantic' | 'action'
|
|
19
|
+
|
|
20
|
+
export type GscAnalyzerAccent = 'primary' | 'warning' | 'success' | 'error' | 'neutral'
|
|
21
|
+
|
|
22
|
+
export interface GscAnalyzerInsightCard {
|
|
23
|
+
icon: string
|
|
24
|
+
accent: GscAnalyzerAccent
|
|
25
|
+
description: string
|
|
26
|
+
summarize: (res: { results: unknown[], meta: Record<string, unknown> }) => { headline: string, tagline: string }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GscAnalyzerStatTile {
|
|
30
|
+
label: string
|
|
31
|
+
value: string | number
|
|
32
|
+
/**
|
|
33
|
+
* Optional CSS color string for the value (used by the few panels that
|
|
34
|
+
* color-code direction, e.g. improved/worsened in change-points).
|
|
35
|
+
*/
|
|
36
|
+
valueColor?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GscAnalyzerPanelResult {
|
|
40
|
+
results: unknown[]
|
|
41
|
+
meta: Record<string, unknown>
|
|
42
|
+
queryMs?: number | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GscAnalyzerPanelSpec {
|
|
46
|
+
/**
|
|
47
|
+
* Body component. Receives `{ rows, meta, range }` as props. Lazy-import
|
|
48
|
+
* via `defineAsyncComponent(() => import(...))` to preserve route-level
|
|
49
|
+
* codesplitting — pages that don't render the analyzer never pay for its
|
|
50
|
+
* chart code.
|
|
51
|
+
*/
|
|
52
|
+
component: Component
|
|
53
|
+
/** Project a result to the header stat tiles. Empty array = no tiles. */
|
|
54
|
+
summarize?: (res: GscAnalyzerPanelResult) => GscAnalyzerStatTile[]
|
|
55
|
+
/** Italic footer caption explaining the underlying method. */
|
|
56
|
+
caption?: string
|
|
57
|
+
/**
|
|
58
|
+
* When true, the shell skips its loading/error/empty gating and renders
|
|
59
|
+
* the body component immediately — the panel manages its own phase state
|
|
60
|
+
* (pipeline panels: action priority, content gap).
|
|
61
|
+
*/
|
|
62
|
+
ownsLifecycle?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GscAnalyzerCapabilities {
|
|
66
|
+
/** Opt into the `/insights` curated grid by providing a card config. */
|
|
67
|
+
insightCard?: GscAnalyzerInsightCard
|
|
68
|
+
/** Opt into `useActionPriority`. The value is the typed `ActionSource` slug. */
|
|
69
|
+
actionPriority?: ActionSource
|
|
70
|
+
/**
|
|
71
|
+
* Opt into the unified `/analyze` panel renderer. Without this, the tab
|
|
72
|
+
* falls back to the generic table renderer in `<GscAnalyzerPanel>`.
|
|
73
|
+
*/
|
|
74
|
+
panel?: GscAnalyzerPanelSpec
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type GscAnalyzerCapability = keyof GscAnalyzerCapabilities
|
|
78
|
+
|
|
79
|
+
export interface GscAnalyzerDefinition {
|
|
80
|
+
/** Stable analyzer id. Must match an `AnalysisTool` slug for built-in analyzers. */
|
|
81
|
+
id: AnalysisTool | (string & {})
|
|
82
|
+
label: string
|
|
83
|
+
kind: GscAnalyzerKind
|
|
84
|
+
/** Reads the per-query table; sums silently drop GSC-anonymized impressions. */
|
|
85
|
+
isQueryGrained?: boolean
|
|
86
|
+
/** Capability opt-ins. Each key gates a downstream consumer. */
|
|
87
|
+
capabilities?: GscAnalyzerCapabilities
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type GscAnalyzerDefinitionWithCapability<K extends GscAnalyzerCapability>
|
|
91
|
+
= GscAnalyzerDefinition & {
|
|
92
|
+
capabilities: { [P in K]: NonNullable<GscAnalyzerCapabilities[P]> }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function defineGscAnalyzer(def: GscAnalyzerDefinition): GscAnalyzerDefinition {
|
|
96
|
+
return def
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function useGscAnalyzerDefs(): GscAnalyzerDefinition[] {
|
|
100
|
+
return (useNuxtApp().$gscAnalyzers as GscAnalyzerDefinition[] | undefined) ?? []
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function useGscAnalyzerDef(id: string): GscAnalyzerDefinition | undefined {
|
|
104
|
+
return useGscAnalyzerDefs().find(a => a.id === id)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Typed filter that narrows defs to those with `capability` opted in.
|
|
109
|
+
* The returned defs have `capabilities[capability]` typed as non-nullable.
|
|
110
|
+
*/
|
|
111
|
+
export function useGscAnalyzerDefsWithCapability<K extends GscAnalyzerCapability>(
|
|
112
|
+
capability: K,
|
|
113
|
+
): GscAnalyzerDefinitionWithCapability<K>[] {
|
|
114
|
+
return useGscAnalyzerDefs().filter(
|
|
115
|
+
(d): d is GscAnalyzerDefinitionWithCapability<K> =>
|
|
116
|
+
d.capabilities?.[capability] != null,
|
|
117
|
+
)
|
|
118
|
+
}
|