@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 +134 -0
- package/dist/api.js +1 -0
- package/dist/app.js +1 -0
- package/package.json +38 -0
- package/src/index.ts +2 -0
- package/src/umami-analytics-endpoint/index.ts +139 -0
- package/src/umami-analytics-panel/index.ts +41 -0
- package/src/umami-analytics-panel/panel.vue +193 -0
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,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>
|