@koendhoore/directus-extension-umami-analytics 1.0.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/README.md ADDED
@@ -0,0 +1,134 @@
1
+ # Directus Umami Analytics Extension
2
+
3
+ A small reusable Directus bundle for self-hosted Umami:
4
+
5
+ - `umami-analytics` endpoint: server-side proxy to Umami, so your Umami credentials are never exposed in the browser.
6
+ - `umami-analytics-panel` panel: native Directus Insights panel showing visitors, pageviews, visits, bounce rate, top pages, referrers, and events.
7
+
8
+ This is intended for your setup:
9
+
10
+ ```text
11
+ Nuxt websites -> analytics.koendhoore.be Umami -> Directus Insights panel
12
+ ```
13
+
14
+ ## 1. Required Directus environment variables
15
+
16
+ Add these to the Directus instance that should show analytics.
17
+
18
+ Recommended first version, one website per Directus instance:
19
+
20
+ ```env
21
+ UMAMI_URL=https://analytics.koendhoore.be
22
+ UMAMI_WEBSITE_ID=7a68c662-b142-45a8-81e1-a8be0c6ddf84
23
+ UMAMI_USERNAME=directus-analytics
24
+ UMAMI_PASSWORD=your-umami-password
25
+ ```
26
+
27
+ Alternative, if you later find/create an Umami API token:
28
+
29
+ ```env
30
+ UMAMI_URL=https://analytics.koendhoore.be
31
+ UMAMI_WEBSITE_ID=7a68c662-b142-45a8-81e1-a8be0c6ddf84
32
+ UMAMI_TOKEN=your-token
33
+ ```
34
+
35
+ Do not put these values in the frontend/Nuxt app except the public tracking script and website id.
36
+
37
+ ## 2. Create a read-only Umami user
38
+
39
+ In Umami, create a dedicated user if available, for example:
40
+
41
+ ```text
42
+ directus-analytics
43
+ ```
44
+
45
+ Give it access only to the website(s) needed for that client/project.
46
+
47
+ For your personal Directus, it can use your own Umami user at first, but a dedicated user is safer.
48
+
49
+ ## 3. Install in Directus
50
+
51
+ From this folder:
52
+
53
+ ```bash
54
+ npm install
55
+ npm run build
56
+ ```
57
+
58
+ Then copy the built extension package into Directus extensions. The exact path depends on your deployment, but commonly:
59
+
60
+ ```text
61
+ /directus/extensions/directus-extension-umami-analytics
62
+ ```
63
+
64
+ Restart Directus.
65
+
66
+ ## 4. Test the endpoint
67
+
68
+ Open this while logged into Directus:
69
+
70
+ ```text
71
+ https://your-directus-domain.com/umami-analytics/health
72
+ ```
73
+
74
+ Expected:
75
+
76
+ ```json
77
+ {"ok":true,"service":"umami-analytics"}
78
+ ```
79
+
80
+ Then test:
81
+
82
+ ```text
83
+ https://your-directus-domain.com/umami-analytics/summary?days=30
84
+ ```
85
+
86
+ Expected: JSON with `stats`, `pages`, `referrers`, and `events`.
87
+
88
+ ## 5. Add the Directus panel
89
+
90
+ In Directus:
91
+
92
+ ```text
93
+ Insights -> Create/Open Dashboard -> Add Panel -> Umami Analytics
94
+ ```
95
+
96
+ Panel settings:
97
+
98
+ - Leave `Umami Website ID` empty if this Directus instance has `UMAMI_WEBSITE_ID` in env.
99
+ - Or paste a specific website ID for this panel.
100
+ - Choose 7, 30, or 90 days.
101
+
102
+ ## 6. Multi-client recommendation
103
+
104
+ For separate client Directus instances, install the same extension everywhere.
105
+
106
+ Client A Directus:
107
+
108
+ ```env
109
+ UMAMI_URL=https://analytics.koendhoore.be
110
+ UMAMI_WEBSITE_ID=client-a-website-id
111
+ UMAMI_USERNAME=directus-analytics-client-a
112
+ UMAMI_PASSWORD=...
113
+ ```
114
+
115
+ Client B Directus:
116
+
117
+ ```env
118
+ UMAMI_URL=https://analytics.koendhoore.be
119
+ UMAMI_WEBSITE_ID=client-b-website-id
120
+ UMAMI_USERNAME=directus-analytics-client-b
121
+ UMAMI_PASSWORD=...
122
+ ```
123
+
124
+ Clients do not need direct Umami login. They only see their Directus dashboard.
125
+
126
+ ## Notes
127
+
128
+ This first version uses Umami's private API paths:
129
+
130
+ - `/api/auth/login`
131
+ - `/api/websites/:id/stats`
132
+ - `/api/websites/:id/metrics`
133
+
134
+ If Umami changes these endpoints in a future release, only the Directus endpoint file needs updating.
package/dist/api.js ADDED
@@ -0,0 +1 @@
1
+ const t=new Map;let e=null;function n(t,e){const n=t[e];if(!n)throw new Error(`Missing ${e}`);return n.replace(/\/$/,"")}function r(t,e){const n=Number(t);return Number.isFinite(n)?n:e}async function a(t,r){const a=n(t,"UMAMI_URL"),s=await async function(t){const r=t.UMAMI_TOKEN||t.UMAMI_API_KEY;if(r)return{headers:{Authorization:`Bearer ${r}`}};if(e&&e.expires>Date.now())return e.auth;const a=n(t,"UMAMI_URL"),s=n(t,"UMAMI_USERNAME"),o=n(t,"UMAMI_PASSWORD"),i=await fetch(`${a}/api/auth/login`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:s,password:o})});if(!i.ok){const t=await i.text();throw new Error(`Umami login failed: ${i.status} ${t}`)}const c=i.headers.get("set-cookie")||"";let u=null;try{u=await i.json()}catch{}const d=u?.token||u?.accessToken||u?.data?.token||u?.data?.accessToken,h=d?{headers:{Authorization:`Bearer ${d}`}}:{headers:c?{Cookie:c.split(";")[0]}:{}};return e={expires:Date.now()+33e5,auth:h},h}(t),o=await fetch(`${a}${r}`,{headers:{Accept:"application/json",...s.headers}});if(!o.ok){const t=await o.text();throw new Error(`Umami request failed: ${o.status} ${t}`)}return o.json()}function s(t,e,n,r,a=10){return`/api/websites/${t}/metrics?${new URLSearchParams({startAt:String(Math.round(n)),endAt:String(Math.round(r)),type:e,limit:String(a)}).toString()}`}const o=[],i=[{name:"umami-analytics",config:(e,n)=>{const o=n.env||process.env;e.get("/health",(t,e)=>{e.json({ok:!0,service:"umami-analytics"})}),e.get("/summary",async(e,n)=>{try{const i=String(e.query.websiteId||o.UMAMI_WEBSITE_ID||"");if(!i)return n.status(400).json({error:"Missing websiteId or UMAMI_WEBSITE_ID"});const{startAt:c,endAt:u,days:d}=function(t){const e=Date.now(),n=Math.max(1,Math.min(365,r(t.days,30))),a=r(t.endAt,e);return{startAt:r(t.startAt,a-24*n*60*60*1e3),endAt:a,days:n}}(e.query),h=`summary:${i}:${c}:${u}`,w=function(e){const n=t.get(e);return!n||n.expires<Date.now()?null:n.value}(h);if(w)return n.json(w);const A=new URLSearchParams({startAt:String(Math.round(c)),endAt:String(Math.round(u))}),[M,l,m,f]=await Promise.all([a(o,`/api/websites/${i}/stats?${A.toString()}`),a(o,s(i,"url",c,u,10)),a(o,s(i,"referrer",c,u,10)),a(o,s(i,"event",c,u,10)).catch(()=>[])]),g={websiteId:i,range:{startAt:c,endAt:u,days:d},stats:M,pages:l,referrers:m,events:f};!function(e,n,r=3e5){t.set(e,{expires:Date.now()+r,value:n})}(h,g),n.json(g)}catch(t){n.status(500).json({error:t.message||"Unknown error"})}})}}],c=[];export{i as endpoints,o as hooks,c as operations};
package/dist/app.js ADDED
@@ -0,0 +1 @@
1
+ import{definePanel as e}from"@directus/extensions-sdk";import{defineComponent as n,ref as t,computed as a,onMounted as s,watch as r,openBlock as l,createElementBlock as i,toDisplayString as o,Fragment as d,createElementVNode as u,renderList as c}from"vue";const p={class:"umami-panel"},m={key:0,class:"state"},v={key:1,class:"state error"},f={class:"cards"},g={class:"card"},y={class:"card"},b={class:"card"},h={class:"card"},x={class:"grid"};var w=n({__name:"panel",props:{websiteId:{},days:{}},setup(e){const n=e,w=t(!0),k=t(""),I=t(null),_=a(()=>n.days||30);function A(e){return null==e?0:"number"==typeof e?e:"number"==typeof e.value?e.value:Number(e.value||e.count||e.y||0)}function E(e){const n=A(I.value?.stats?.[e]);return(new Intl.NumberFormat).format(n)}function S(e){const n=A(I.value?.stats?.[e]);return n?Math.round(100*n)/100+"%":"0%"}const D=a(()=>I.value?.pages||[]),L=a(()=>I.value?.referrers||[]),T=a(()=>I.value?.events||[]);async function j(){w.value=!0,k.value="";const e=new URLSearchParams({days:String(_.value)});n.websiteId&&e.set("websiteId",n.websiteId);try{const n=await fetch(`/umami-analytics/summary?${e.toString()}`,{credentials:"include",headers:{Accept:"application/json"}}),t=await n.json();if(!n.ok)throw new Error(t?.error||`Request failed: ${n.status}`);I.value=t}catch(e){k.value=e?.message||"Could not load analytics."}finally{w.value=!1}}return s(j),r(()=>[n.websiteId,n.days],j),(e,n)=>(l(),i("div",p,[w.value?(l(),i("div",m,"Loading analytics...")):k.value?(l(),i("div",v,o(k.value),1)):(l(),i(d,{key:2},[u("div",f,[u("div",g,[n[0]||(n[0]=u("span",null,"Visitors",-1)),u("strong",null,o(E("visitors")),1)]),u("div",y,[n[1]||(n[1]=u("span",null,"Pageviews",-1)),u("strong",null,o(E("pageviews")),1)]),u("div",b,[n[2]||(n[2]=u("span",null,"Visits",-1)),u("strong",null,o(E("visits")),1)]),u("div",h,[n[3]||(n[3]=u("span",null,"Bounce rate",-1)),u("strong",null,o(S("bounces")),1)])]),u("div",x,[u("section",null,[n[4]||(n[4]=u("h3",null,"Top pages",-1)),u("ul",null,[(l(!0),i(d,null,c(D.value,e=>(l(),i("li",{key:e.x||e.name},[u("span",null,o(e.x||e.name||"(unknown)"),1),u("b",null,o(e.y||e.value||e.count),1)]))),128))])]),u("section",null,[n[5]||(n[5]=u("h3",null,"Referrers",-1)),u("ul",null,[(l(!0),i(d,null,c(L.value,e=>(l(),i("li",{key:e.x||e.name},[u("span",null,o(e.x||e.name||"Direct"),1),u("b",null,o(e.y||e.value||e.count),1)]))),128))])]),u("section",null,[n[6]||(n[6]=u("h3",null,"Events",-1)),u("ul",null,[(l(!0),i(d,null,c(T.value,e=>(l(),i("li",{key:e.x||e.name},[u("span",null,o(e.x||e.name||"(event)"),1),u("b",null,o(e.y||e.value||e.count),1)]))),128))])])])],64))]))}}),k=[],I=[];!function(e,n){if(e&&"undefined"!=typeof document){var t,a=!0===n.prepend?"prepend":"append",s=!0===n.singleTag,r="string"==typeof n.container?document.querySelector(n.container):document.getElementsByTagName("head")[0];if(s){var l=k.indexOf(r);-1===l&&(l=k.push(r)-1,I[l]={}),t=I[l]&&I[l][a]?I[l][a]:I[l][a]=i()}else t=i();65279===e.charCodeAt(0)&&(e=e.substring(1)),t.styleSheet?t.styleSheet.cssText+=e:t.appendChild(document.createTextNode(e))}function i(){var e=document.createElement("style");if(e.setAttribute("type","text/css"),n.attributes)for(var t=Object.keys(n.attributes),s=0;s<t.length;s++)e.setAttribute(t[s],n.attributes[t[s]]);var l="prepend"===a?"afterbegin":"beforeend";return r.insertAdjacentElement(l,e),e}}("\n.umami-panel[data-v-062f2277] {\n padding: 16px;\n height: 100%;\n overflow: auto;\n}\n.state[data-v-062f2277] {\n padding: 16px;\n color: var(--theme--foreground-subdued);\n}\n.error[data-v-062f2277] {\n color: var(--theme--danger);\n}\n.cards[data-v-062f2277] {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 18px;\n}\n.card[data-v-062f2277] {\n border: var(--theme--border-width) solid var(--theme--border-color);\n border-radius: var(--theme--border-radius);\n padding: 14px;\n background: var(--theme--background-normal);\n}\n.card span[data-v-062f2277] {\n display: block;\n color: var(--theme--foreground-subdued);\n font-size: 12px;\n margin-bottom: 6px;\n}\n.card strong[data-v-062f2277] {\n font-size: 24px;\n line-height: 1;\n}\n.grid[data-v-062f2277] {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 18px;\n}\nh3[data-v-062f2277] {\n margin: 0 0 8px;\n font-size: 14px;\n}\nul[data-v-062f2277] {\n list-style: none;\n margin: 0;\n padding: 0;\n}\nli[data-v-062f2277] {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding: 7px 0;\n border-bottom: 1px solid var(--theme--border-color-subdued);\n}\nli span[data-v-062f2277] {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\nli b[data-v-062f2277] {\n flex: 0 0 auto;\n}\n@media (max-width: 900px) {\n.cards[data-v-062f2277],\n .grid[data-v-062f2277] {\n grid-template-columns: 1fr;\n}\n}\n",{});const _=[],A=[],E=[],S=[],D=[e({id:"umami-analytics-panel",name:"Umami Analytics",icon:"monitoring",description:"Native Directus panel for Umami visitors, pageviews, top pages, referrers, and events.",component:((e,n)=>{const t=e.__vccOpts||e;for(const[e,a]of n)t[e]=a;return t})(w,[["__scopeId","data-v-062f2277"]]),options:[{field:"websiteId",name:"Umami Website ID",type:"string",meta:{width:"full",interface:"input",note:"Optional. Leave empty to use UMAMI_WEBSITE_ID from the Directus environment."}},{field:"days",name:"Date Range",type:"integer",schema:{default_value:30},meta:{width:"half",interface:"select-dropdown",options:{choices:[{text:"Last 7 days",value:7},{text:"Last 30 days",value:30},{text:"Last 90 days",value:90}]}}}],minWidth:18,minHeight:12})],L=[],T=[];export{A as displays,_ as interfaces,E as layouts,S as modules,T as operations,D as panels,L as themes};
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@koendhoore/directus-extension-umami-analytics",
3
+ "version": "1.0.0",
4
+ "description": "Directus bundle: secure Umami proxy endpoint + native Insights analytics panel.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "scripts": {
8
+ "build": "directus-extension build",
9
+ "dev": "directus-extension build --watch",
10
+ "add": "directus-extension add"
11
+ },
12
+ "dependencies": {},
13
+ "devDependencies": {
14
+ "@directus/extensions-sdk": "latest",
15
+ "typescript": "latest",
16
+ "vue": "latest"
17
+ },
18
+ "directus:extension": {
19
+ "type": "bundle",
20
+ "path": {
21
+ "app": "dist/app.js",
22
+ "api": "dist/api.js"
23
+ },
24
+ "entries": [
25
+ {
26
+ "type": "endpoint",
27
+ "name": "umami-analytics",
28
+ "source": "src/umami-analytics-endpoint/index.ts"
29
+ },
30
+ {
31
+ "type": "panel",
32
+ "name": "umami-analytics-panel",
33
+ "source": "src/umami-analytics-panel/index.ts"
34
+ }
35
+ ],
36
+ "host": "^11.0.0"
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default as umamiAnalyticsEndpoint } from './umami-analytics-endpoint/index';
2
+ export { default as umamiAnalyticsPanel } from './umami-analytics-panel/index';
@@ -0,0 +1,139 @@
1
+ type Env = Record<string, string | undefined>;
2
+
3
+ type UmamiAuth = {
4
+ headers: Record<string, string>;
5
+ cookie?: string;
6
+ };
7
+
8
+ const FIVE_MINUTES = 5 * 60 * 1000;
9
+ const cache = new Map<string, { expires: number; value: unknown }>();
10
+ let authCache: { expires: number; auth: UmamiAuth } | null = null;
11
+
12
+ function required(env: Env, key: string): string {
13
+ const value = env[key];
14
+ if (!value) throw new Error(`Missing ${key}`);
15
+ return value.replace(/\/$/, '');
16
+ }
17
+
18
+ function numberParam(value: unknown, fallback: number) {
19
+ const parsed = Number(value);
20
+ return Number.isFinite(parsed) ? parsed : fallback;
21
+ }
22
+
23
+ function getRange(query: Record<string, unknown>) {
24
+ const now = Date.now();
25
+ const days = Math.max(1, Math.min(365, numberParam(query.days, 30)));
26
+ const endAt = numberParam(query.endAt, now);
27
+ const startAt = numberParam(query.startAt, endAt - days * 24 * 60 * 60 * 1000);
28
+ return { startAt, endAt, days };
29
+ }
30
+
31
+ function cached<T>(key: string): T | null {
32
+ const hit = cache.get(key);
33
+ if (!hit || hit.expires < Date.now()) return null;
34
+ return hit.value as T;
35
+ }
36
+
37
+ function setCached(key: string, value: unknown, ttlMs = FIVE_MINUTES) {
38
+ cache.set(key, { expires: Date.now() + ttlMs, value });
39
+ }
40
+
41
+ async function getAuth(env: Env): Promise<UmamiAuth> {
42
+ const token = env.UMAMI_TOKEN || env.UMAMI_API_KEY;
43
+ if (token) return { headers: { Authorization: `Bearer ${token}` } };
44
+
45
+ if (authCache && authCache.expires > Date.now()) return authCache.auth;
46
+
47
+ const baseUrl = required(env, 'UMAMI_URL');
48
+ const username = required(env, 'UMAMI_USERNAME');
49
+ const password = required(env, 'UMAMI_PASSWORD');
50
+
51
+ const response = await fetch(`${baseUrl}/api/auth/login`, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ username, password }),
55
+ });
56
+
57
+ if (!response.ok) {
58
+ const text = await response.text();
59
+ throw new Error(`Umami login failed: ${response.status} ${text}`);
60
+ }
61
+
62
+ const setCookie = response.headers.get('set-cookie') || '';
63
+ let json: any = null;
64
+ try {
65
+ json = await response.json();
66
+ } catch {
67
+ // Some Umami versions rely on cookies only.
68
+ }
69
+
70
+ const bearer = json?.token || json?.accessToken || json?.data?.token || json?.data?.accessToken;
71
+ const auth: UmamiAuth = bearer
72
+ ? { headers: { Authorization: `Bearer ${bearer}` } }
73
+ : { headers: setCookie ? { Cookie: setCookie.split(';')[0] } : {} };
74
+
75
+ authCache = { expires: Date.now() + 55 * 60 * 1000, auth };
76
+ return auth;
77
+ }
78
+
79
+ async function umamiGet(env: Env, path: string) {
80
+ const baseUrl = required(env, 'UMAMI_URL');
81
+ const auth = await getAuth(env);
82
+ const response = await fetch(`${baseUrl}${path}`, {
83
+ headers: {
84
+ Accept: 'application/json',
85
+ ...auth.headers,
86
+ },
87
+ });
88
+
89
+ if (!response.ok) {
90
+ const text = await response.text();
91
+ throw new Error(`Umami request failed: ${response.status} ${text}`);
92
+ }
93
+
94
+ return response.json();
95
+ }
96
+
97
+ function metricPath(websiteId: string, type: string, startAt: number, endAt: number, limit = 10) {
98
+ const params = new URLSearchParams({
99
+ startAt: String(Math.round(startAt)),
100
+ endAt: String(Math.round(endAt)),
101
+ type,
102
+ limit: String(limit),
103
+ });
104
+ return `/api/websites/${websiteId}/metrics?${params.toString()}`;
105
+ }
106
+
107
+ export default (router: any, context: any) => {
108
+ const env: Env = context.env || process.env;
109
+
110
+ router.get('/health', (_req: any, res: any) => {
111
+ res.json({ ok: true, service: 'umami-analytics' });
112
+ });
113
+
114
+ router.get('/summary', async (req: any, res: any) => {
115
+ try {
116
+ const websiteId = String(req.query.websiteId || env.UMAMI_WEBSITE_ID || '');
117
+ if (!websiteId) return res.status(400).json({ error: 'Missing websiteId or UMAMI_WEBSITE_ID' });
118
+
119
+ const { startAt, endAt, days } = getRange(req.query);
120
+ const key = `summary:${websiteId}:${startAt}:${endAt}`;
121
+ const hit = cached(key);
122
+ if (hit) return res.json(hit);
123
+
124
+ const params = new URLSearchParams({ startAt: String(Math.round(startAt)), endAt: String(Math.round(endAt)) });
125
+ const [stats, pages, referrers, events] = await Promise.all([
126
+ umamiGet(env, `/api/websites/${websiteId}/stats?${params.toString()}`),
127
+ umamiGet(env, metricPath(websiteId, 'url', startAt, endAt, 10)),
128
+ umamiGet(env, metricPath(websiteId, 'referrer', startAt, endAt, 10)),
129
+ umamiGet(env, metricPath(websiteId, 'event', startAt, endAt, 10)).catch(() => []),
130
+ ]);
131
+
132
+ const payload = { websiteId, range: { startAt, endAt, days }, stats, pages, referrers, events };
133
+ setCached(key, payload);
134
+ res.json(payload);
135
+ } catch (error: any) {
136
+ res.status(500).json({ error: error.message || 'Unknown error' });
137
+ }
138
+ });
139
+ };
@@ -0,0 +1,41 @@
1
+ import { definePanel } from '@directus/extensions-sdk';
2
+ import PanelComponent from './panel.vue';
3
+
4
+ export default definePanel({
5
+ id: 'umami-analytics-panel',
6
+ name: 'Umami Analytics',
7
+ icon: 'monitoring',
8
+ description: 'Native Directus panel for Umami visitors, pageviews, top pages, referrers, and events.',
9
+ component: PanelComponent,
10
+ options: [
11
+ {
12
+ field: 'websiteId',
13
+ name: 'Umami Website ID',
14
+ type: 'string',
15
+ meta: {
16
+ width: 'full',
17
+ interface: 'input',
18
+ note: 'Optional. Leave empty to use UMAMI_WEBSITE_ID from the Directus environment.',
19
+ },
20
+ },
21
+ {
22
+ field: 'days',
23
+ name: 'Date Range',
24
+ type: 'integer',
25
+ schema: { default_value: 30 },
26
+ meta: {
27
+ width: 'half',
28
+ interface: 'select-dropdown',
29
+ options: {
30
+ choices: [
31
+ { text: 'Last 7 days', value: 7 },
32
+ { text: 'Last 30 days', value: 30 },
33
+ { text: 'Last 90 days', value: 90 },
34
+ ],
35
+ },
36
+ },
37
+ },
38
+ ],
39
+ minWidth: 18,
40
+ minHeight: 12,
41
+ });
@@ -0,0 +1,193 @@
1
+ <template>
2
+ <div class="umami-panel">
3
+ <div v-if="loading" class="state">Loading analytics...</div>
4
+ <div v-else-if="error" class="state error">{{ error }}</div>
5
+ <template v-else>
6
+ <div class="cards">
7
+ <div class="card">
8
+ <span>Visitors</span>
9
+ <strong>{{ stat('visitors') }}</strong>
10
+ </div>
11
+ <div class="card">
12
+ <span>Pageviews</span>
13
+ <strong>{{ stat('pageviews') }}</strong>
14
+ </div>
15
+ <div class="card">
16
+ <span>Visits</span>
17
+ <strong>{{ stat('visits') }}</strong>
18
+ </div>
19
+ <div class="card">
20
+ <span>Bounce rate</span>
21
+ <strong>{{ percentStat('bounces') }}</strong>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="grid">
26
+ <section>
27
+ <h3>Top pages</h3>
28
+ <ul>
29
+ <li v-for="row in pages" :key="row.x || row.name">
30
+ <span>{{ row.x || row.name || '(unknown)' }}</span>
31
+ <b>{{ row.y || row.value || row.count }}</b>
32
+ </li>
33
+ </ul>
34
+ </section>
35
+
36
+ <section>
37
+ <h3>Referrers</h3>
38
+ <ul>
39
+ <li v-for="row in referrers" :key="row.x || row.name">
40
+ <span>{{ row.x || row.name || 'Direct' }}</span>
41
+ <b>{{ row.y || row.value || row.count }}</b>
42
+ </li>
43
+ </ul>
44
+ </section>
45
+
46
+ <section>
47
+ <h3>Events</h3>
48
+ <ul>
49
+ <li v-for="row in events" :key="row.x || row.name">
50
+ <span>{{ row.x || row.name || '(event)' }}</span>
51
+ <b>{{ row.y || row.value || row.count }}</b>
52
+ </li>
53
+ </ul>
54
+ </section>
55
+ </div>
56
+ </template>
57
+ </div>
58
+ </template>
59
+
60
+ <script setup lang="ts">
61
+ import { computed, onMounted, ref, watch } from 'vue';
62
+
63
+ const props = defineProps<{
64
+ websiteId?: string;
65
+ days?: number;
66
+ }>();
67
+
68
+ const loading = ref(true);
69
+ const error = ref('');
70
+ const data = ref<any>(null);
71
+
72
+ const days = computed(() => props.days || 30);
73
+
74
+ function valueOfMetric(metric: any) {
75
+ if (metric == null) return 0;
76
+ if (typeof metric === 'number') return metric;
77
+ if (typeof metric.value === 'number') return metric.value;
78
+ return Number(metric.value || metric.count || metric.y || 0);
79
+ }
80
+
81
+ function stat(key: string) {
82
+ const value = valueOfMetric(data.value?.stats?.[key]);
83
+ return new Intl.NumberFormat().format(value);
84
+ }
85
+
86
+ function percentStat(key: string) {
87
+ const value = valueOfMetric(data.value?.stats?.[key]);
88
+ if (!value) return '0%';
89
+ return `${Math.round(value * 100) / 100}%`;
90
+ }
91
+
92
+ const pages = computed(() => data.value?.pages || []);
93
+ const referrers = computed(() => data.value?.referrers || []);
94
+ const events = computed(() => data.value?.events || []);
95
+
96
+ async function load() {
97
+ loading.value = true;
98
+ error.value = '';
99
+
100
+ const params = new URLSearchParams({ days: String(days.value) });
101
+ if (props.websiteId) params.set('websiteId', props.websiteId);
102
+
103
+ try {
104
+ const response = await fetch(`/umami-analytics/summary?${params.toString()}`, {
105
+ credentials: 'include',
106
+ headers: { Accept: 'application/json' },
107
+ });
108
+
109
+ const json = await response.json();
110
+ if (!response.ok) throw new Error(json?.error || `Request failed: ${response.status}`);
111
+ data.value = json;
112
+ } catch (err: any) {
113
+ error.value = err?.message || 'Could not load analytics.';
114
+ } finally {
115
+ loading.value = false;
116
+ }
117
+ }
118
+
119
+ onMounted(load);
120
+ watch(() => [props.websiteId, props.days], load);
121
+ </script>
122
+
123
+ <style scoped>
124
+ .umami-panel {
125
+ padding: 16px;
126
+ height: 100%;
127
+ overflow: auto;
128
+ }
129
+ .state {
130
+ padding: 16px;
131
+ color: var(--theme--foreground-subdued);
132
+ }
133
+ .error {
134
+ color: var(--theme--danger);
135
+ }
136
+ .cards {
137
+ display: grid;
138
+ grid-template-columns: repeat(4, minmax(0, 1fr));
139
+ gap: 12px;
140
+ margin-bottom: 18px;
141
+ }
142
+ .card {
143
+ border: var(--theme--border-width) solid var(--theme--border-color);
144
+ border-radius: var(--theme--border-radius);
145
+ padding: 14px;
146
+ background: var(--theme--background-normal);
147
+ }
148
+ .card span {
149
+ display: block;
150
+ color: var(--theme--foreground-subdued);
151
+ font-size: 12px;
152
+ margin-bottom: 6px;
153
+ }
154
+ .card strong {
155
+ font-size: 24px;
156
+ line-height: 1;
157
+ }
158
+ .grid {
159
+ display: grid;
160
+ grid-template-columns: repeat(3, minmax(0, 1fr));
161
+ gap: 18px;
162
+ }
163
+ h3 {
164
+ margin: 0 0 8px;
165
+ font-size: 14px;
166
+ }
167
+ ul {
168
+ list-style: none;
169
+ margin: 0;
170
+ padding: 0;
171
+ }
172
+ li {
173
+ display: flex;
174
+ justify-content: space-between;
175
+ gap: 12px;
176
+ padding: 7px 0;
177
+ border-bottom: 1px solid var(--theme--border-color-subdued);
178
+ }
179
+ li span {
180
+ overflow: hidden;
181
+ text-overflow: ellipsis;
182
+ white-space: nowrap;
183
+ }
184
+ li b {
185
+ flex: 0 0 auto;
186
+ }
187
+ @media (max-width: 900px) {
188
+ .cards,
189
+ .grid {
190
+ grid-template-columns: 1fr;
191
+ }
192
+ }
193
+ </style>