@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Harlan Wilton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @gscdump/nuxt
|
|
2
|
+
|
|
3
|
+
[](https://npmjs.com/package/@gscdump/nuxt)
|
|
4
|
+
[](https://npm.chart.dev/@gscdump/nuxt)
|
|
5
|
+
[](https://github.com/harlan-zw/gscdump/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
> Nuxt layer: GSC analytics UI, Nuxt auth/runtime wiring, and DuckDB-WASM integration. It uses `@gscdump/sdk` internally for hosted analytics HTTP reads.
|
|
8
|
+
|
|
9
|
+
This package assumes a site is already linked. It does not own partner
|
|
10
|
+
onboarding, registration, lifecycle decisions, token exchange, or webhook
|
|
11
|
+
subscription management. Use `@gscdump/sdk` from the host app for those
|
|
12
|
+
flows, then pass the linked `gscdumpSiteId` into the analytics composables
|
|
13
|
+
here.
|
|
14
|
+
|
|
15
|
+
Three consumption modes:
|
|
16
|
+
|
|
17
|
+
| Mode | Who | What it does |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| `origin` | gscdump.com | Reads R2 data directly (binding) + DuckDB service binding. Owns the data. |
|
|
20
|
+
| `consumer` | nuxtseo.com | Proxies all reads to an origin over HTTP + an API key. No data locally. |
|
|
21
|
+
| `local` | `examples/nuxt-dashboard` | Reads a `gscdump sync` dump from `GSCDUMP_DATA_DIR`. Offline dev. |
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add @gscdump/nuxt
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// nuxt.config.ts
|
|
31
|
+
export default defineNuxtConfig({
|
|
32
|
+
extends: ['@gscdump/nuxt'],
|
|
33
|
+
runtimeConfig: {
|
|
34
|
+
analytics: {
|
|
35
|
+
mode: 'origin', // or 'consumer' / 'local'
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Register an auth provider
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// server/plugins/register-auth.ts
|
|
45
|
+
export default defineNitroPlugin((nitro) => {
|
|
46
|
+
nitro.hooks.hook('analytics:register-auth-provider', (register) => {
|
|
47
|
+
register({
|
|
48
|
+
resolve: async (event) => {
|
|
49
|
+
const session = await getUserSession(event)
|
|
50
|
+
if (!session)
|
|
51
|
+
return null
|
|
52
|
+
return { userId: session.user.id, siteIds: session.accessibleSiteIds }
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Data access: which endpoint?
|
|
60
|
+
|
|
61
|
+
| Need | Endpoint | Client composable |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| Pre-aggregated widgets (top pages, daily totals) | `GET /api/sites/:siteId/rollup/:id` | `useGscRollup` / `useGscRollups` / `useGscRollupFanout` |
|
|
64
|
+
| Raw rows filtered by dimension/range (`where(eq(page, ...))`) | `POST /api/sites/:siteId/rows` | `useGscRowQuery({ site, state })` |
|
|
65
|
+
| Analyzer result (striking-distance, CTR anomaly, …) | `POST /api/sites/:siteId/analyze` | `useGscAnalyzer(siteId).analyze(...)` |
|
|
66
|
+
|
|
67
|
+
Rule of thumb: reach for `/rows` + `useGscRowQuery` for detail pages that
|
|
68
|
+
need a freeform slice of the data. Only fall back to `/analyze` when the
|
|
69
|
+
server runs an actual analyzer (anything in the registry); hitting it for
|
|
70
|
+
plain row lookups routes through analyzer-gating logic you don't need.
|
|
71
|
+
|
|
72
|
+
## Consumer mode (cross-origin)
|
|
73
|
+
|
|
74
|
+
Used when the Nuxt app authenticating the viewer is not the same origin as
|
|
75
|
+
the data — e.g. nuxtseo.com pro consuming gscdump.com. Two pieces:
|
|
76
|
+
|
|
77
|
+
1. **Point fetch at the origin**: set `runtimeConfig.public.analytics.apiBase`
|
|
78
|
+
in `nuxt.config.ts` (or via `NUXT_PUBLIC_ANALYTICS_API_BASE`). Empty =
|
|
79
|
+
same-origin (the default origin-mode shape).
|
|
80
|
+
|
|
81
|
+
2. **Hand the layer a per-viewer api key**: register a client plugin that
|
|
82
|
+
trades the viewer's host session for an api key, then call
|
|
83
|
+
`setGscFetchHeaders({ 'x-api-key': key })`. The layer's `useGscFetch`
|
|
84
|
+
attaches the header to every `/api/__gsc/*` call automatically. The same
|
|
85
|
+
header rides parquet GETs in `attachParquetUrlTables` (DuckDB-WASM
|
|
86
|
+
browser path), so R2 reads work cross-origin.
|
|
87
|
+
|
|
88
|
+
The `setupGscFetchAuth` helper collapses the boilerplate:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// plugins/00.gscdump-auth.client.ts
|
|
92
|
+
export default setupGscFetchAuth({
|
|
93
|
+
credentialsEndpoint: '/api/me/gscdump-credentials',
|
|
94
|
+
// Optional: preload host-specific state. Runs inside runWithContext so
|
|
95
|
+
// composables work. Useful for useState-backed flags read by the layer's
|
|
96
|
+
// composables at construction time.
|
|
97
|
+
async onReady() {
|
|
98
|
+
const flag = useState<boolean | null>('app:browser-analyzer-enabled', () => null)
|
|
99
|
+
if (flag.value !== null)
|
|
100
|
+
return
|
|
101
|
+
const settings = await useGscFetch()<{ browserAnalyzerEnabled: boolean }>('/api/user/settings').catch(() => null)
|
|
102
|
+
flag.value = !!settings?.browserAnalyzerEnabled
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The helper is `enforce: 'pre'` and dedupes via an inflight promise; Nuxt
|
|
108
|
+
blocks subsequent plugins until headers land, removing the auth-header race
|
|
109
|
+
where a `useGscQuery` mounts before credentials resolve.
|
|
110
|
+
|
|
111
|
+
`credentialsEndpoint` returns `{ apiKey: string, ... }` by default. Override
|
|
112
|
+
the field name with `tokenField` and the header name with `headerName`.
|
|
113
|
+
|
|
114
|
+
Onboarding and lifecycle state stay outside this layer. A consumer app should
|
|
115
|
+
use `@gscdump/sdk` from its host backend to register users/sites, read
|
|
116
|
+
`GET /api/partner/users/:userId/lifecycle`, handle webhook or realtime
|
|
117
|
+
invalidation, and decide when a linked site is ready to render. Once it has a
|
|
118
|
+
linked site id, this layer handles query reads and analytics UI only.
|
|
119
|
+
|
|
120
|
+
### CORS
|
|
121
|
+
|
|
122
|
+
The origin app (`gscdump.com` or your own) must allow the consumer origin
|
|
123
|
+
on `/api/__gsc/*`, `/api/r2-data/*`, and any host endpoints the helper
|
|
124
|
+
calls. Allow-headers must include `x-api-key`, `Cache-Control`, and `Range`
|
|
125
|
+
(DuckDB-WASM uses partial reads); expose `Content-Range`, `Accept-Ranges`,
|
|
126
|
+
`Content-Length`. Preflight needs to cover `PATCH` for any settings calls.
|
|
127
|
+
|
|
128
|
+
## Related
|
|
129
|
+
|
|
130
|
+
- [`@gscdump/sdk`](../sdk) — framework-agnostic hosted API client used by host onboarding code and by this Nuxt layer internally.
|
|
131
|
+
- [`gscdump`](../gscdump) — local package and query builder.
|
|
132
|
+
- [`@gscdump/engine`](../engine) — Storage engine consumed in origin mode.
|
|
133
|
+
- [`@gscdump/engine-duckdb-wasm`](../engine-duckdb-wasm) — DuckDB-WASM browser runtime used by client-side widgets.
|
|
134
|
+
- [`@gscdump/analysis`](../analysis) — Analyzers powering `/analyze` endpoints.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
[MIT](../../LICENSE)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Unified shell for `/analyze` analyzer tabs. Drives the chrome that 11
|
|
3
|
+
// per-analyzer sections used to repeat: stat tiles, body, caption. The body
|
|
4
|
+
// component + tile projection + caption come from `def.capabilities.panel`;
|
|
5
|
+
// the shell owns the loading/error/empty gating unless `panel.ownsLifecycle`
|
|
6
|
+
// is set (pipeline panels manage their own phase state internally).
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
GscAnalyzerDefinition,
|
|
10
|
+
GscAnalyzerPanelResult,
|
|
11
|
+
} from '../composables/useGscAnalyzerDefs'
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
def: GscAnalyzerDefinition
|
|
15
|
+
rows: unknown[]
|
|
16
|
+
meta: Record<string, unknown> | null
|
|
17
|
+
queryMs?: number | null
|
|
18
|
+
loading?: boolean
|
|
19
|
+
error?: string | null
|
|
20
|
+
/** Forwarded to the body component as `range`. Optional. */
|
|
21
|
+
range?: { start: string, end: string } | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const props = defineProps<Props>()
|
|
25
|
+
|
|
26
|
+
const panel = computed(() => props.def.capabilities?.panel)
|
|
27
|
+
|
|
28
|
+
const tiles = computed(() => {
|
|
29
|
+
const p = panel.value
|
|
30
|
+
if (!p?.summarize)
|
|
31
|
+
return []
|
|
32
|
+
const result: GscAnalyzerPanelResult = {
|
|
33
|
+
results: props.rows,
|
|
34
|
+
meta: props.meta ?? {},
|
|
35
|
+
queryMs: props.queryMs ?? null,
|
|
36
|
+
}
|
|
37
|
+
return p.summarize(result)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const showBody = computed(() => {
|
|
41
|
+
if (!panel.value)
|
|
42
|
+
return false
|
|
43
|
+
if (panel.value.ownsLifecycle)
|
|
44
|
+
return true
|
|
45
|
+
return !props.loading && !props.error && props.rows.length > 0
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const bodyProps = computed(() => ({
|
|
49
|
+
rows: props.rows,
|
|
50
|
+
meta: props.meta ?? {},
|
|
51
|
+
range: props.range ?? null,
|
|
52
|
+
}))
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<section v-if="panel" class="gsc-analyzer-panel">
|
|
57
|
+
<div v-if="tiles.length > 0" class="gsc-analyzer-panel__tiles">
|
|
58
|
+
<div v-for="(t, i) in tiles" :key="`${t.label}-${i}`" class="gsc-analyzer-panel__tile">
|
|
59
|
+
<span class="gsc-analyzer-panel__tile-label">{{ t.label }}</span>
|
|
60
|
+
<span class="gsc-analyzer-panel__tile-value" :style="t.valueColor ? { color: t.valueColor } : undefined">{{ t.value }}</span>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<template v-if="!panel.ownsLifecycle">
|
|
65
|
+
<div v-if="error" class="gsc-analyzer-panel__error">
|
|
66
|
+
{{ error }}
|
|
67
|
+
</div>
|
|
68
|
+
<div v-else-if="loading" class="gsc-analyzer-panel__loading">
|
|
69
|
+
Running analyzer…
|
|
70
|
+
</div>
|
|
71
|
+
<div v-else-if="rows.length === 0" class="gsc-analyzer-panel__empty">
|
|
72
|
+
No rows.
|
|
73
|
+
</div>
|
|
74
|
+
</template>
|
|
75
|
+
|
|
76
|
+
<component :is="panel.component" v-if="showBody" v-bind="bodyProps" />
|
|
77
|
+
|
|
78
|
+
<p v-if="panel.caption" class="gsc-analyzer-panel__caption">
|
|
79
|
+
<slot name="caption">{{ panel.caption }}</slot>
|
|
80
|
+
</p>
|
|
81
|
+
</section>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<style scoped>
|
|
85
|
+
.gsc-analyzer-panel { background: #fff; border: 1px solid #ececef; border-top: 0; border-radius: 0 0 6px 6px; padding: 1rem 1rem 0.9rem; }
|
|
86
|
+
.gsc-analyzer-panel__tiles { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 0.85rem; padding: 0 0.15rem; }
|
|
87
|
+
.gsc-analyzer-panel__tile { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
88
|
+
.gsc-analyzer-panel__tile-label { font-size: 0.66rem; letter-spacing: 0.08em; text-transform: uppercase; color: #888; }
|
|
89
|
+
.gsc-analyzer-panel__tile-value { font-size: 1.3rem; font-weight: 600; color: #1d1d1f; font-variant-numeric: tabular-nums; }
|
|
90
|
+
.gsc-analyzer-panel__caption { margin: 0.8rem 0 0; font-size: 0.78rem; color: #666; font-style: italic; text-align: center; }
|
|
91
|
+
.gsc-analyzer-panel__caption :deep(code) { background: #f0f0f3; padding: 0.05rem 0.3rem; border-radius: 3px; font-size: 0.74rem; color: #4c3ca0; }
|
|
92
|
+
.gsc-analyzer-panel__error { padding: 1rem 1.25rem; color: #c00; background: #fff5f5; font-family: ui-monospace, monospace; font-size: 0.82rem; white-space: pre-wrap; }
|
|
93
|
+
.gsc-analyzer-panel__loading, .gsc-analyzer-panel__empty { padding: 2.5rem; text-align: center; color: var(--ui-text-dimmed); font-size: 0.9rem; }
|
|
94
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Surfaces the trailing-28d impression-weighted anonymization % from the
|
|
3
|
+
// daily_totals rollup. GSC anonymises queries for low-volume or sensitive
|
|
4
|
+
// terms, so query-grained breakdowns (keywords, pages-by-query) undercount
|
|
5
|
+
// impressions by this amount. Hidden when data isn't ready or the gap is
|
|
6
|
+
// negligible (<1%).
|
|
7
|
+
|
|
8
|
+
import { useGscRollup } from '../composables/useGscRollup'
|
|
9
|
+
import { weightedAnonPct } from '../utils/anonymization'
|
|
10
|
+
|
|
11
|
+
interface DailyTotal {
|
|
12
|
+
impressions: number
|
|
13
|
+
anonymizedImpressionsPct: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { siteIdentifier } = defineProps<{
|
|
17
|
+
siteIdentifier: string | null | undefined
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
const { data: daily } = useGscRollup<DailyTotal[]>(() => siteIdentifier ?? null, 'daily_totals')
|
|
21
|
+
|
|
22
|
+
const formatted = computed(() => {
|
|
23
|
+
if (!siteIdentifier)
|
|
24
|
+
return null
|
|
25
|
+
const pct = weightedAnonPct(daily.value)
|
|
26
|
+
if (pct == null)
|
|
27
|
+
return null
|
|
28
|
+
const percent = pct * 100
|
|
29
|
+
if (percent < 1)
|
|
30
|
+
return null
|
|
31
|
+
return percent.toFixed(percent < 10 ? 1 : 0)
|
|
32
|
+
})
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div
|
|
37
|
+
v-if="formatted"
|
|
38
|
+
class="flex items-start gap-2 border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-200/90"
|
|
39
|
+
>
|
|
40
|
+
<UIcon name="i-ph-info" class="mt-0.5 flex-shrink-0 text-amber-400" />
|
|
41
|
+
<div>
|
|
42
|
+
<span class="font-semibold">~{{ formatted }}% of impressions are anonymised by Google.</span>
|
|
43
|
+
Query-grained breakdowns below exclude these, so the sum won't match site-level totals.
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Minimal boot-progress indicator for the browser-side DuckDB-WASM + parquet
|
|
3
|
+
// attach flow. Reads per-site progress straight off the injected analyzer
|
|
4
|
+
// state (`siteProgress` on `BrowserAnalyzerState`) — no derived composable,
|
|
5
|
+
// no prop drilling.
|
|
6
|
+
//
|
|
7
|
+
// Collapsed view: one slim stacked bar, each segment a site, coloured by
|
|
8
|
+
// stage. Tap to expand per-site rows with file counters. Hidden once every
|
|
9
|
+
// tracked site is ready.
|
|
10
|
+
|
|
11
|
+
import type { SiteLoadProgress, SiteLoadStage } from '../composables/useGscAnalytics'
|
|
12
|
+
import { useGscBootProgress } from '../composables/useGscAnalytics'
|
|
13
|
+
|
|
14
|
+
// Reads the shared per-site progress ref directly. Both DuckDB boot and rollup
|
|
15
|
+
// fan-out write to it, so this component lights up for either flow without
|
|
16
|
+
// caring who's producing the signal.
|
|
17
|
+
const { progress: siteProgress } = useGscBootProgress()
|
|
18
|
+
|
|
19
|
+
const expanded = ref(false)
|
|
20
|
+
|
|
21
|
+
const sites = computed<SiteLoadProgress[]>(() =>
|
|
22
|
+
Object.values(siteProgress.value).sort((a, b) => a.startedAt - b.startedAt),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const allReady = computed(() => sites.value.length > 0 && sites.value.every(s => s.stage === 'ready'))
|
|
26
|
+
const anyActive = computed(() => sites.value.some(s => s.stage !== 'ready' && s.stage !== 'idle' && s.stage !== 'error'))
|
|
27
|
+
|
|
28
|
+
const aggregate = computed(() => {
|
|
29
|
+
let attached = 0
|
|
30
|
+
let total = 0
|
|
31
|
+
for (const s of sites.value) {
|
|
32
|
+
attached += s.filesAttached
|
|
33
|
+
total += s.filesTotal
|
|
34
|
+
}
|
|
35
|
+
return { attached, total, pct: total > 0 ? Math.round((attached / total) * 100) : 0 }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Rank stages so the earliest-stage site drives the overall headline.
|
|
39
|
+
const stageRank: Record<SiteLoadStage, number> = {
|
|
40
|
+
idle: 0,
|
|
41
|
+
wasm: 1,
|
|
42
|
+
manifest: 2,
|
|
43
|
+
attach: 3,
|
|
44
|
+
ready: 4,
|
|
45
|
+
error: -1,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const earliestStage = computed<SiteLoadStage>(() => {
|
|
49
|
+
let min: SiteLoadStage = 'ready'
|
|
50
|
+
let minRank = stageRank.ready
|
|
51
|
+
for (const s of sites.value) {
|
|
52
|
+
const r = stageRank[s.stage]
|
|
53
|
+
if (r >= 0 && r < minRank) {
|
|
54
|
+
min = s.stage
|
|
55
|
+
minRank = r
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return min
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const stageLabel: Record<SiteLoadStage, string> = {
|
|
62
|
+
idle: 'Queued',
|
|
63
|
+
wasm: 'Starting DuckDB',
|
|
64
|
+
manifest: 'Fetching manifest',
|
|
65
|
+
attach: 'Attaching parquet',
|
|
66
|
+
ready: 'Ready',
|
|
67
|
+
error: 'Error',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const stageColor: Record<SiteLoadStage, string> = {
|
|
71
|
+
idle: 'bg-neutral-700',
|
|
72
|
+
wasm: 'bg-violet-500',
|
|
73
|
+
manifest: 'bg-sky-500',
|
|
74
|
+
attach: 'bg-emerald-500',
|
|
75
|
+
ready: 'bg-emerald-600',
|
|
76
|
+
error: 'bg-rose-500',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function siteWidth(site: SiteLoadProgress): string {
|
|
80
|
+
if (site.stage === 'ready')
|
|
81
|
+
return '100%'
|
|
82
|
+
if (site.filesTotal === 0)
|
|
83
|
+
// No file count yet (wasm / manifest phase) — show a fractional sliver so
|
|
84
|
+
// the segment is visible but clearly not done.
|
|
85
|
+
return site.stage === 'wasm' ? '10%' : '25%'
|
|
86
|
+
return `${Math.max(5, Math.round((site.filesAttached / site.filesTotal) * 100))}%`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function truncate(id: string, max = 28): string {
|
|
90
|
+
if (id.length <= max)
|
|
91
|
+
return id
|
|
92
|
+
return `${id.slice(0, max - 1)}…`
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<template>
|
|
97
|
+
<Transition
|
|
98
|
+
enter-active-class="transition-all duration-200"
|
|
99
|
+
enter-from-class="opacity-0 translate-y-[-4px]"
|
|
100
|
+
enter-to-class="opacity-100 translate-y-0"
|
|
101
|
+
leave-active-class="transition-all duration-300"
|
|
102
|
+
leave-from-class="opacity-100"
|
|
103
|
+
leave-to-class="opacity-0"
|
|
104
|
+
>
|
|
105
|
+
<div
|
|
106
|
+
v-if="!allReady && sites.length > 0"
|
|
107
|
+
class="analytics-boot-progress"
|
|
108
|
+
>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
class="headline"
|
|
112
|
+
:aria-expanded="expanded"
|
|
113
|
+
@click="expanded = !expanded"
|
|
114
|
+
>
|
|
115
|
+
<span class="dot" :class="stageColor[earliestStage]" />
|
|
116
|
+
<span class="label">{{ stageLabel[earliestStage] }}</span>
|
|
117
|
+
<span class="counter">
|
|
118
|
+
<template v-if="aggregate.total > 0">
|
|
119
|
+
{{ aggregate.attached }} / {{ aggregate.total }}
|
|
120
|
+
</template>
|
|
121
|
+
<template v-else>
|
|
122
|
+
{{ sites.length }} site{{ sites.length === 1 ? '' : 's' }}
|
|
123
|
+
</template>
|
|
124
|
+
</span>
|
|
125
|
+
<span class="caret" :class="{ open: expanded }">⌄</span>
|
|
126
|
+
</button>
|
|
127
|
+
|
|
128
|
+
<div class="bar">
|
|
129
|
+
<div
|
|
130
|
+
v-for="site in sites"
|
|
131
|
+
:key="site.siteId"
|
|
132
|
+
class="seg"
|
|
133
|
+
:class="[stageColor[site.stage], { shimmer: anyActive && site.stage !== 'ready' && site.stage !== 'error' }]"
|
|
134
|
+
:style="{ width: siteWidth(site) }"
|
|
135
|
+
:title="`${site.siteId}: ${stageLabel[site.stage]}`"
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<Transition
|
|
140
|
+
enter-active-class="transition-all duration-200"
|
|
141
|
+
enter-from-class="opacity-0 max-h-0"
|
|
142
|
+
enter-to-class="opacity-100 max-h-[400px]"
|
|
143
|
+
leave-active-class="transition-all duration-150"
|
|
144
|
+
leave-from-class="opacity-100 max-h-[400px]"
|
|
145
|
+
leave-to-class="opacity-0 max-h-0"
|
|
146
|
+
>
|
|
147
|
+
<ul v-if="expanded" class="rows">
|
|
148
|
+
<li v-for="site in sites" :key="site.siteId" class="row">
|
|
149
|
+
<span class="row-dot" :class="stageColor[site.stage]" />
|
|
150
|
+
<span class="row-id">{{ truncate(site.siteId) }}</span>
|
|
151
|
+
<span class="row-stage">{{ stageLabel[site.stage] }}</span>
|
|
152
|
+
<span class="row-count">
|
|
153
|
+
<template v-if="site.filesTotal > 0">
|
|
154
|
+
{{ site.filesAttached }}/{{ site.filesTotal }}
|
|
155
|
+
</template>
|
|
156
|
+
<template v-else-if="site.stage === 'ready' && site.endedAt">
|
|
157
|
+
{{ Math.round(site.endedAt - site.startedAt) }}ms
|
|
158
|
+
</template>
|
|
159
|
+
<template v-else-if="site.stage === 'error'">
|
|
160
|
+
<span class="err">{{ truncate(site.error ?? 'error', 40) }}</span>
|
|
161
|
+
</template>
|
|
162
|
+
<template v-else>
|
|
163
|
+
—
|
|
164
|
+
</template>
|
|
165
|
+
</span>
|
|
166
|
+
</li>
|
|
167
|
+
</ul>
|
|
168
|
+
</Transition>
|
|
169
|
+
</div>
|
|
170
|
+
</Transition>
|
|
171
|
+
</template>
|
|
172
|
+
|
|
173
|
+
<style scoped>
|
|
174
|
+
.analytics-boot-progress {
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-direction: column;
|
|
177
|
+
gap: 6px;
|
|
178
|
+
padding: 8px 10px;
|
|
179
|
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
180
|
+
background: rgba(255, 255, 255, 0.02);
|
|
181
|
+
border-radius: 4px;
|
|
182
|
+
font-size: 12px;
|
|
183
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
184
|
+
}
|
|
185
|
+
.headline {
|
|
186
|
+
display: flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
gap: 8px;
|
|
189
|
+
background: transparent;
|
|
190
|
+
border: 0;
|
|
191
|
+
padding: 0;
|
|
192
|
+
color: inherit;
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
text-align: left;
|
|
195
|
+
width: 100%;
|
|
196
|
+
}
|
|
197
|
+
.dot {
|
|
198
|
+
width: 8px;
|
|
199
|
+
height: 8px;
|
|
200
|
+
border-radius: 999px;
|
|
201
|
+
flex-shrink: 0;
|
|
202
|
+
}
|
|
203
|
+
.label {
|
|
204
|
+
color: rgba(255, 255, 255, 0.82);
|
|
205
|
+
font-weight: 500;
|
|
206
|
+
}
|
|
207
|
+
.counter {
|
|
208
|
+
color: rgba(255, 255, 255, 0.55);
|
|
209
|
+
margin-left: auto;
|
|
210
|
+
font-variant-numeric: tabular-nums;
|
|
211
|
+
}
|
|
212
|
+
.caret {
|
|
213
|
+
display: inline-block;
|
|
214
|
+
color: rgba(255, 255, 255, 0.4);
|
|
215
|
+
transition: transform 180ms ease;
|
|
216
|
+
width: 10px;
|
|
217
|
+
text-align: center;
|
|
218
|
+
}
|
|
219
|
+
.caret.open {
|
|
220
|
+
transform: rotate(180deg);
|
|
221
|
+
}
|
|
222
|
+
.bar {
|
|
223
|
+
display: flex;
|
|
224
|
+
gap: 2px;
|
|
225
|
+
height: 3px;
|
|
226
|
+
width: 100%;
|
|
227
|
+
overflow: hidden;
|
|
228
|
+
border-radius: 2px;
|
|
229
|
+
background: rgba(255, 255, 255, 0.04);
|
|
230
|
+
}
|
|
231
|
+
.seg {
|
|
232
|
+
height: 100%;
|
|
233
|
+
border-radius: 2px;
|
|
234
|
+
transition: width 220ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
|
235
|
+
min-width: 4px;
|
|
236
|
+
position: relative;
|
|
237
|
+
}
|
|
238
|
+
.seg.shimmer::after {
|
|
239
|
+
content: '';
|
|
240
|
+
position: absolute;
|
|
241
|
+
inset: 0;
|
|
242
|
+
background: linear-gradient(
|
|
243
|
+
90deg,
|
|
244
|
+
rgba(255, 255, 255, 0) 0%,
|
|
245
|
+
rgba(255, 255, 255, 0.28) 50%,
|
|
246
|
+
rgba(255, 255, 255, 0) 100%
|
|
247
|
+
);
|
|
248
|
+
animation: boot-shimmer 1.4s linear infinite;
|
|
249
|
+
}
|
|
250
|
+
@keyframes boot-shimmer {
|
|
251
|
+
from { transform: translateX(-100%); }
|
|
252
|
+
to { transform: translateX(100%); }
|
|
253
|
+
}
|
|
254
|
+
.rows {
|
|
255
|
+
list-style: none;
|
|
256
|
+
margin: 4px 0 0;
|
|
257
|
+
padding: 0;
|
|
258
|
+
overflow: hidden;
|
|
259
|
+
}
|
|
260
|
+
.row {
|
|
261
|
+
display: grid;
|
|
262
|
+
grid-template-columns: 10px minmax(0, 1fr) auto auto;
|
|
263
|
+
gap: 8px;
|
|
264
|
+
align-items: center;
|
|
265
|
+
padding: 3px 0;
|
|
266
|
+
color: rgba(255, 255, 255, 0.72);
|
|
267
|
+
}
|
|
268
|
+
.row + .row {
|
|
269
|
+
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
|
270
|
+
}
|
|
271
|
+
.row-dot {
|
|
272
|
+
width: 6px;
|
|
273
|
+
height: 6px;
|
|
274
|
+
border-radius: 999px;
|
|
275
|
+
margin-left: 2px;
|
|
276
|
+
}
|
|
277
|
+
.row-id {
|
|
278
|
+
overflow: hidden;
|
|
279
|
+
text-overflow: ellipsis;
|
|
280
|
+
white-space: nowrap;
|
|
281
|
+
color: rgba(255, 255, 255, 0.88);
|
|
282
|
+
}
|
|
283
|
+
.row-stage {
|
|
284
|
+
color: rgba(255, 255, 255, 0.5);
|
|
285
|
+
font-size: 11px;
|
|
286
|
+
}
|
|
287
|
+
.row-count {
|
|
288
|
+
color: rgba(255, 255, 255, 0.7);
|
|
289
|
+
font-variant-numeric: tabular-nums;
|
|
290
|
+
font-size: 11px;
|
|
291
|
+
min-width: 60px;
|
|
292
|
+
text-align: right;
|
|
293
|
+
}
|
|
294
|
+
.row-count .err {
|
|
295
|
+
color: #fb7185;
|
|
296
|
+
}
|
|
297
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Cmd+K palette. Wraps UCommandPalette inside a UModal, wires up the global
|
|
3
|
+
// keyboard shortcut, and sources its items from the analytics context + a
|
|
4
|
+
// static list of top-level routes. Drop `<GscCommandPalette />` into your
|
|
5
|
+
// root layout once.
|
|
6
|
+
//
|
|
7
|
+
// Hosts can extend the groups via the `groups` prop — e.g. nuxtseo.com adds
|
|
8
|
+
// its billing / settings entries without forking the component.
|
|
9
|
+
|
|
10
|
+
import type { CommandPaletteGroup, CommandPaletteItem } from '@nuxt/ui'
|
|
11
|
+
import type { SiteListItem } from '../composables/useGscAnalytics'
|
|
12
|
+
import { useMagicKeys, whenever } from '@vueuse/core'
|
|
13
|
+
import { useGscSites } from '../composables/useGscAnalytics'
|
|
14
|
+
|
|
15
|
+
const { groups: extraGroups = [] } = defineProps<{
|
|
16
|
+
groups?: CommandPaletteGroup[]
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const router = useRouter()
|
|
20
|
+
const { sites } = useGscSites()
|
|
21
|
+
|
|
22
|
+
const open = ref(false)
|
|
23
|
+
const selected = ref<CommandPaletteItem | null>(null)
|
|
24
|
+
|
|
25
|
+
const keys = useMagicKeys()
|
|
26
|
+
const cmdK = computed(() => keys.meta_k?.value || keys.ctrl_k?.value || false)
|
|
27
|
+
whenever(cmdK, () => {
|
|
28
|
+
open.value = !open.value
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const siteGroup = computed<CommandPaletteGroup<CommandPaletteItem>>(() => ({
|
|
32
|
+
id: 'sites',
|
|
33
|
+
label: 'Sites',
|
|
34
|
+
items: (sites.value ?? []).map<CommandPaletteItem>((s: SiteListItem) => ({
|
|
35
|
+
label: s.hostname,
|
|
36
|
+
suffix: s.propertyType === 'domain' ? 'sc-domain' : 'url-prefix',
|
|
37
|
+
icon: 'i-lucide-globe',
|
|
38
|
+
to: `/sites/${encodeURIComponent(s.id)}`,
|
|
39
|
+
})),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
const navGroup: CommandPaletteGroup<CommandPaletteItem> = {
|
|
43
|
+
id: 'nav',
|
|
44
|
+
label: 'Navigation',
|
|
45
|
+
items: [
|
|
46
|
+
{ label: 'Overview', icon: 'i-lucide-layout-dashboard', to: '/' },
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const allGroups = computed<CommandPaletteGroup[]>(() => [
|
|
51
|
+
siteGroup.value,
|
|
52
|
+
navGroup,
|
|
53
|
+
...extraGroups,
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
watch(selected, (item) => {
|
|
57
|
+
if (!item)
|
|
58
|
+
return
|
|
59
|
+
if (typeof item.to === 'string')
|
|
60
|
+
router.push(item.to)
|
|
61
|
+
open.value = false
|
|
62
|
+
selected.value = null
|
|
63
|
+
})
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<UModal v-model:open="open" :ui="{ content: 'max-w-[560px]' }">
|
|
68
|
+
<template #content>
|
|
69
|
+
<UCommandPalette
|
|
70
|
+
v-model="selected"
|
|
71
|
+
:groups="allGroups"
|
|
72
|
+
placeholder="Jump to a site or action…"
|
|
73
|
+
class="h-[420px]"
|
|
74
|
+
/>
|
|
75
|
+
</template>
|
|
76
|
+
</UModal>
|
|
77
|
+
</template>
|