@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/app/assets/css/main.css +2 -0
  4. package/app/components/GscAnalyzerPanel.vue +94 -0
  5. package/app/components/GscAnonymizationBanner.vue +46 -0
  6. package/app/components/GscBootProgress.vue +297 -0
  7. package/app/components/GscCommandPalette.vue +77 -0
  8. package/app/components/GscDashboardPage.vue +26 -0
  9. package/app/components/GscEngineBadge.vue +28 -0
  10. package/app/components/GscHero.vue +215 -0
  11. package/app/components/GscLazyTopResult.vue +45 -0
  12. package/app/components/GscPerformanceChart.vue +532 -0
  13. package/app/components/GscPositionDistributionChart.vue +63 -0
  14. package/app/components/GscQueryLabel.vue +63 -0
  15. package/app/components/GscSitePageHeader.vue +40 -0
  16. package/app/components/GscSourceDebugPanel.vue +195 -0
  17. package/app/components/GscSyncStatusCell.vue +54 -0
  18. package/app/components/GscTopRollupCard.vue +90 -0
  19. package/app/composables/_useGscBackfill.ts +111 -0
  20. package/app/composables/_useGscQueryDispatcher.ts +122 -0
  21. package/app/composables/_useGscResource.ts +114 -0
  22. package/app/composables/_useGscSharedSiteResource.ts +136 -0
  23. package/app/composables/useGscAnalytics.ts +197 -0
  24. package/app/composables/useGscAnalyticsClient.ts +9 -0
  25. package/app/composables/useGscAnalyticsConfig.ts +8 -0
  26. package/app/composables/useGscAnalyticsSourceInfo.ts +143 -0
  27. package/app/composables/useGscAnalyzer.ts +374 -0
  28. package/app/composables/useGscAnalyzerBatch.ts +106 -0
  29. package/app/composables/useGscAnalyzerDefs.ts +118 -0
  30. package/app/composables/useGscAuth.ts +115 -0
  31. package/app/composables/useGscConsoleUrl.ts +45 -0
  32. package/app/composables/useGscCountries.ts +47 -0
  33. package/app/composables/useGscCurrentSite.ts +15 -0
  34. package/app/composables/useGscEngine.ts +16 -0
  35. package/app/composables/useGscInspectionHistory.ts +42 -0
  36. package/app/composables/useGscInspections.ts +52 -0
  37. package/app/composables/useGscPanelContext.ts +24 -0
  38. package/app/composables/useGscParquetTable.ts +199 -0
  39. package/app/composables/useGscPeriod.ts +243 -0
  40. package/app/composables/useGscQuery.ts +290 -0
  41. package/app/composables/useGscQueryDispatcher.ts +122 -0
  42. package/app/composables/useGscRequestIndexingInspect.ts +20 -0
  43. package/app/composables/useGscResource.ts +114 -0
  44. package/app/composables/useGscRollup.ts +252 -0
  45. package/app/composables/useGscRollupTable.ts +78 -0
  46. package/app/composables/useGscRowQuery.ts +56 -0
  47. package/app/composables/useGscSearchAppearance.ts +47 -0
  48. package/app/composables/useGscSitemapHistory.ts +41 -0
  49. package/app/composables/useGscSitemaps.ts +45 -0
  50. package/app/composables/useGscTableState.ts +119 -0
  51. package/app/plugins/analytics.ts +57 -0
  52. package/app/utils/anonymization.ts +24 -0
  53. package/app/utils/country-names.ts +56 -0
  54. package/app/utils/gsc-constants.ts +10 -0
  55. package/app/utils/gsc-error.ts +58 -0
  56. package/app/utils/gsc-fetch.ts +94 -0
  57. package/app/utils/gsc-filters.ts +32 -0
  58. package/app/utils/gsc-rows.ts +72 -0
  59. package/app/utils/position.ts +7 -0
  60. package/app/utils/setup-gsc-fetch-auth.ts +62 -0
  61. package/module.ts +81 -0
  62. package/nuxt.config.ts +36 -0
  63. package/package.json +75 -0
  64. 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
+ }