@koendhoore/directus-extension-umami-analytics 1.0.3 → 2.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/dist/api.js CHANGED
@@ -1 +1 @@
1
- import t from"node:crypto";const e=new Map;let r=null;function n(t,e){const r=t[e];if(!r)throw new Error(`Missing ${e}`);return r.replace(/\/+$/,"")}function a(t){return n(t,"UMAMI_URL").replace(/\/api\/?$/,"")}function s(t,e){const r=e.replace(/^\/api\/?/,"").replace(/^\/+/,"");return`${a(t)}/api/${r}`}function o(t,e){const r=Number(t);return Number.isFinite(r)?r:e}function i(t){return Buffer.from(t).toString("base64").replace(/=/g,"").replace(/\+/g,"-").replace(/\//g,"_")}function c(e,r){return function(e,r){const n=t.randomBytes(16),a=t.randomBytes(64),s=t.pbkdf2Sync(r,a,1e4,32,"sha512"),o=t.createCipheriv("aes-256-gcm",s,n),i=Buffer.concat([o.update(String(e),"utf8"),o.final()]),c=o.getAuthTag();return Buffer.concat([a,n,c,i]).toString("base64")}(function(e,r){const n=`${i(JSON.stringify({alg:"HS256",typ:"JWT"}))}.${i(JSON.stringify(e))}`;return`${n}.${i(t.createHmac("sha256",r).update(n).digest())}`}(e,r),r)}function u(e){const r=e.UMAMI_USER_ID||e.UMAMI_API_CLIENT_USER_ID,n=e.UMAMI_APP_SECRET||e.UMAMI_API_CLIENT_SECRET;if(!r||!n)return null;return{headers:{Authorization:`Bearer ${c({userId:r},function(...e){return t.createHash("sha512").update(e.join("")).digest("hex")}(n))}`}}}async function f(t){const e=t.UMAMI_API_KEY;if(e)return{headers:{"x-umami-api-key":e}};const a=t.UMAMI_TOKEN;if(a)return{headers:{Authorization:`Bearer ${a}`}};const o=u(t);return o||async function(t){if(r&&r.expires>Date.now())return r.auth;const e=n(t,"UMAMI_USERNAME"),a=n(t,"UMAMI_PASSWORD"),o=await fetch(s(t,"/auth/login"),{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({username:e,password:a})}),i=await o.text();if(!o.ok)throw new Error(`Umami login failed: ${o.status} ${i}`);let c=null;try{c=i?JSON.parse(i):null}catch{}const u=c?.token||c?.accessToken||c?.data?.token||c?.data?.accessToken,f=o.headers.get("set-cookie")||"",d=u?{headers:{Authorization:`Bearer ${u}`}}:{headers:f?{Cookie:f.split(";")[0]}:{}};return r={expires:Date.now()+33e5,auth:d},d}(t)}async function d(t,e){const r=await f(t),n=await fetch(s(t,e),{headers:{Accept:"application/json",...r.headers}}),a=await n.text();if(!n.ok)throw new Error(`Umami request failed: ${n.status} ${a}`);return a?JSON.parse(a):null}function h(t,e,r,n,a=10){return`/websites/${t}/metrics?${new URLSearchParams({startAt:String(Math.round(r)),endAt:String(Math.round(n)),type:e,limit:String(a)}).toString()}`}async function p(t,e,r=[]){try{return await d(t,e)}catch(t){return{error:t.message||"Unknown Umami error",path:e,fallback:r}}}const m=[],l=[{name:"umami-analytics",config:(t,r)=>{const n=r.env||process.env;t.get("/health",(t,e)=>{e.json({ok:!0,service:"umami-analytics"})}),t.get("/debug",async(t,e)=>{try{const t=n.UMAMI_API_KEY?"api-key":n.UMAMI_TOKEN?"bearer-token":n.UMAMI_USER_ID||n.UMAMI_API_CLIENT_USER_ID?"api-client":"username-password",r=await d(n,"/me").catch(t=>({error:t.message}));e.json({ok:!0,authMode:t,umamiUrl:a(n),websiteId:n.UMAMI_WEBSITE_ID,me:r})}catch(t){e.status(500).json({error:t.message||"Unknown error"})}}),t.get("/summary",async(t,r)=>{try{const a=String(t.query.websiteId||n.UMAMI_WEBSITE_ID||"");if(!a)return r.status(400).json({error:"Missing websiteId or UMAMI_WEBSITE_ID"});const{startAt:s,endAt:i,days:c}=function(t){const e=Date.now(),r=Math.max(1,Math.min(365,o(t.days,30))),n=o(t.endAt,e);return{startAt:o(t.startAt,n-24*r*60*60*1e3),endAt:n,days:r}}(t.query),u=`summary:${a}:${s}:${i}:v3`,f=function(t){const r=e.get(t);return!r||r.expires<Date.now()?null:r.value}(u);if(f)return r.json(f);const m=new URLSearchParams({startAt:String(Math.round(s)),endAt:String(Math.round(i))}),l=await d(n,`/websites/${a}/stats?${m.toString()}`),[M,A,g]=await Promise.all([p(n,h(a,"path",s,i,10),[]),p(n,h(a,"referrer",s,i,10),[]),p(n,h(a,"event",s,i,10),[])]),I={websiteId:a,range:{startAt:s,endAt:i,days:c},stats:l,pages:M,referrers:A,events:g};!function(t,r,n=3e5){e.set(t,{expires:Date.now()+n,value:r})}(u,I),r.json(I)}catch(t){r.status(500).json({error:t.message||"Unknown error"})}})}}],M=[];export{l as endpoints,m as hooks,M as operations};
1
+ import t from"node:crypto";const e=new Map;let r=null;function n(t,e){const r=t[e];if(!r)throw new Error(`Missing ${e}`);return r.replace(/\/+$/,"")}function a(t,e){const r=e.replace(/^\/api\/?/,"").replace(/^\/+/,"");return`${function(t){return n(t,"UMAMI_URL").replace(/\/api\/?$/,"")}(t)}/api/${r}`}function s(t,e){const r=Number(t);return Number.isFinite(r)?r:e}function i(t){return Buffer.from(t).toString("base64").replace(/=/g,"").replace(/\+/g,"-").replace(/\//g,"_")}function o(e,r){return function(e,r){const n=t.randomBytes(16),a=t.randomBytes(64),s=t.pbkdf2Sync(r,a,1e4,32,"sha512"),i=t.createCipheriv("aes-256-gcm",s,n),o=Buffer.concat([i.update(String(e),"utf8"),i.final()]),u=i.getAuthTag();return Buffer.concat([a,n,u,o]).toString("base64")}(function(e,r){const n=`${i(JSON.stringify({alg:"HS256",typ:"JWT"}))}.${i(JSON.stringify(e))}`;return`${n}.${i(t.createHmac("sha256",r).update(n).digest())}`}(e,r),r)}function u(e){const r=e.UMAMI_USER_ID||e.UMAMI_API_CLIENT_USER_ID,n=e.UMAMI_APP_SECRET||e.UMAMI_API_CLIENT_SECRET;if(!r||!n)return null;return{headers:{Authorization:`Bearer ${o({userId:r},function(...e){return t.createHash("sha512").update(e.join("")).digest("hex")}(n))}`}}}async function c(t){const e=t.UMAMI_API_KEY;if(e)return{headers:{"x-umami-api-key":e}};const s=t.UMAMI_TOKEN;if(s)return{headers:{Authorization:`Bearer ${s}`}};const i=u(t);return i||async function(t){if(r&&r.expires>Date.now())return r.auth;const e=n(t,"UMAMI_USERNAME"),s=n(t,"UMAMI_PASSWORD"),i=await fetch(a(t,"/auth/login"),{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({username:e,password:s})}),o=await i.text();if(!i.ok)throw new Error(`Umami login failed: ${i.status} ${o}`);let u=null;try{u=o?JSON.parse(o):null}catch{}const c=u?.token||u?.accessToken||u?.data?.token||u?.data?.accessToken,d=i.headers.get("set-cookie")||"",f=c?{headers:{Authorization:`Bearer ${c}`}}:{headers:d?{Cookie:d.split(";")[0]}:{}};return r={expires:Date.now()+33e5,auth:f},f}(t)}async function d(t,e){const r=await c(t),n=await fetch(a(t,e),{headers:{Accept:"application/json",...r.headers}}),s=await n.text();if(!n.ok)throw new Error(`Umami request failed: ${n.status} ${s}`);return s?JSON.parse(s):null}async function f(t,e,r=[]){try{return await d(t,e)}catch(t){return r}}function p(t,e,r,n,a=10){return`/websites/${t}/metrics?${new URLSearchParams({startAt:String(Math.round(r)),endAt:String(Math.round(n)),type:e,limit:String(a)}).toString()}`}function h(t,e,r,n){return`/websites/${t}/pageviews?${new URLSearchParams({startAt:String(Math.round(e)),endAt:String(Math.round(r)),unit:n}).toString()}`}const g=[],l=[{name:"umami-analytics",config:(t,r)=>{const n=r.env||process.env;t.get("/health",(t,e)=>{e.json({ok:!0,service:"umami-analytics",version:"2.0.0"})}),t.get("/summary",async(t,r)=>{try{const a=String(t.query.websiteId||n.UMAMI_WEBSITE_ID||"");if(!a)return r.status(400).json({error:"Missing websiteId or UMAMI_WEBSITE_ID"});const{startAt:i,endAt:o,days:u,preset:c}=function(t){const e=Date.now(),r=String(t.preset||t.range||"").toLowerCase();if("today"===r){const t=new Date;return t.setHours(0,0,0,0),{startAt:t.getTime(),endAt:e,days:1,preset:r}}const n=Math.max(1,Math.min(365,s(t.days,{"7d":7,last7:7,last_7_days:7,"30d":30,last30:30,last_30_days:30,"90d":90,last90:90,last_90_days:90}[r]||30))),a=s(t.endAt,e);return{startAt:s(t.startAt,a-24*n*60*60*1e3),endAt:a,days:n,preset:r||`${n}d`}}(t.query),g=String(t.query.unit||function(t){return t<=2?"hour":t<=90?"day":"month"}(u)),l=`summary:v2:${a}:${i}:${o}:${g}`,A=function(t){const r=e.get(t);return!r||r.expires<Date.now()?null:r.value}(l);if(A)return r.json(A);const w=new URLSearchParams({startAt:String(Math.round(i)),endAt:String(Math.round(o))}),y=await d(n,`/websites/${a}/stats?${w.toString()}`),[m,S,M,_,$,I,U,E]=await Promise.all([f(n,h(a,i,o,g),{pageviews:[],sessions:[]}),f(n,p(a,"path",i,o,12),[]),f(n,p(a,"referrer",i,o,12),[]),f(n,p(a,"country",i,o,12),[]),f(n,p(a,"browser",i,o,12),[]),f(n,p(a,"os",i,o,12),[]),f(n,p(a,"device",i,o,12),[]),f(n,p(a,"event",i,o,12),[])]),v={websiteId:a,range:{startAt:i,endAt:o,days:u,preset:c,unit:g},stats:y,pageviews:m,pages:S,referrers:M,countries:_,browsers:$,os:I,devices:U,events:E};!function(t,r,n=3e5){e.set(t,{expires:Date.now()+n,value:r})}(l,v),r.json(v)}catch(t){r.status(500).json({error:t.message||"Unknown error"})}})}}],A=[];export{l as endpoints,g as hooks,A as operations};
package/dist/app.js CHANGED
@@ -1 +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};
1
+ import{definePanel as a}from"@directus/extensions-sdk";import{defineComponent as e,ref as n,computed as t,h as r,onMounted as i,watch as s,openBlock as l,createElementBlock as o,createElementVNode as d,Fragment as c,renderList as u,normalizeClass as f,toDisplayString as p,createTextVNode as v,createVNode as b,unref as m}from"vue";const h={class:"umami-panel"},g={class:"panel-scroll"},y={class:"topbar"},x={class:"controls"},w=["onClick"],k=["disabled"],A={key:0,class:"state"},N={key:1,class:"state error"},L={class:"kpis"},$={class:"chart-card"},I={class:"section-head"},j={class:"chart-wrap"},z={key:0,viewBox:"0 0 700 260",preserveAspectRatio:"none",role:"img"},M=["y1","y2"],S=["d"],T=["d"],_=["d"],C=["cx","cy"],D={key:1,class:"empty-chart"},E={class:"lists"};var R=e({__name:"panel",props:{websiteId:{},days:{}},setup(a){const R=a,B=[{label:"Today",value:"today",days:1},{label:"7d",value:"7d",days:7},{label:"30d",value:"30d",days:30},{label:"90d",value:"90d",days:90},{label:"All",value:"365d",days:365}],U=n(R.days?`${R.days}d`:"30d"),V=n(!0),Y=n(""),q=n(null),O=t(()=>B.find(a=>a.value===U.value)||B[2]),W=t(()=>"All"===O.value.label?"Last 365 days":"Today"===O.value.label?"Today":`Last ${O.value.days} days`);function P(a){return null==a?0:"number"==typeof a?a:"number"==typeof a.value?a.value:Number(a.value||a.count||a.y||0)}function F(a){return(new Intl.NumberFormat).format(Math.round(Number(a||0)))}function H(a){return F(P(q.value?.stats?.[a]))}function Z(a){const e=P(q.value?.stats?.[a]);return e?Math.round(10*e)/10+"%":"0%"}const G=t(()=>[{label:"Visitors",value:H("visitors")},{label:"Visits",value:H("visits")},{label:"Pageviews",value:H("pageviews")},{label:"Bounce rate",value:Z("bounces")},{label:"Visit duration",value:`${F(P(q.value?.stats?.totaltime))}s`}]);function J(a){return Array.isArray(a)?a.filter(a=>!a?.error):[]}const K=t(()=>J(q.value?.pages)),Q=t(()=>J(q.value?.referrers)),X=t(()=>J(q.value?.countries)),aa=t(()=>J(q.value?.devices)),ea=t(()=>J(q.value?.browsers)),na=t(()=>J(q.value?.events));function ta(a){return a?.x||a?.date||a?.t||a?.createdAt||a?.day||""}function ra(a){return Number(a?.y||a?.pageviews||a?.views||a?.value||a?.count||0)}function ia(a){return Number(a?.visitors||a?.sessions||a?.uniques||a?.users||ra(a))}const sa=t(()=>{const a=q.value?.pageviews;var e;return(Array.isArray(a?.pageviews)?a.pageviews.map((e,n)=>({date:ta(e),pageviews:ra(e),visitors:ia(a.sessions?.[n]||e)})):(e=a,Array.isArray(e)?e:Array.isArray(e?.pageviews)?e.pageviews:Array.isArray(e?.views)?e.views:Array.isArray(e?.data)?e.data:[]).map(a=>({date:ta(a),pageviews:ra(a),visitors:ia(a)}))).filter(a=>a.pageviews||a.visitors)}),la=t(()=>{const a=sa.value,e=220,n=Math.max(...a.map(a=>Math.max(a.pageviews,a.visitors)),1);return a.map((t,r)=>{const i=1===a.length?350:r/(a.length-1)*700,s=238-t.pageviews/n*e,l=238-t.visitors/n*e;return{...t,index:r,x:i,pageviewsY:s,visitorsY:l}})}),oa=[40,95,150,205];function da(a){const e=la.value;if(!e.length)return"";const n="pageviews"===a?"pageviewsY":"visitorsY";return e.map((a,e)=>`${0===e?"M":"L"} ${a.x} ${a[n]}`).join(" ")}function ca(a){const e=la.value;if(!e.length)return"";const n=da(a),t=e[e.length-1],r=e[0];return`${n} L ${t.x} 240 L ${r.x} 240 Z`}async function ua(){V.value=!0,Y.value="";const a=new URLSearchParams,e=O.value;"today"===e.value?a.set("preset","today"):a.set("days",String(e.days)),R.websiteId&&a.set("websiteId",R.websiteId);try{const e=await fetch(`/umami-analytics/summary?${a.toString()}`,{credentials:"include",headers:{Accept:"application/json"}}),n=await e.json();if(!e.ok)throw new Error(n?.error||`Request failed: ${e.status}`);q.value=n}catch(a){Y.value=a?.message||"Could not load analytics."}finally{V.value=!1}}const fa=e({name:"MetricList",props:{title:{type:String,required:!0},rows:{type:Array,default:()=>[]},empty:{type:String,default:"No data yet."},fallbackLabel:{type:String,default:"(unknown)"}},setup(a){function e(e){return String(e?.x||e?.name||e?.title||e?.label||a.fallbackLabel)}function n(a){return Number(a?.y||a?.value||a?.count||0)}function t(a){return(new Intl.NumberFormat).format(n(a))}function i(e){const t=Math.max(...a.rows.map(n),1);return`${Math.max(5,Math.round(n(e)/t*100))}%`}return()=>r("article",{class:"metric-card"},[r("header",[r("h3",a.title)]),a.rows.length?r("ul",a.rows.map(a=>r("li",{key:e(a)},[r("span",{class:"metric-label",title:e(a)},e(a)),r("span",{class:"metric-bar","aria-hidden":"true"},[r("span",{style:{width:i(a)}})]),r("b",t(a))]))):r("p",{class:"empty"},a.empty)])}});return i(ua),s(()=>[R.websiteId,R.days],()=>{R.days&&(U.value=`${R.days}d`),ua()}),(a,e)=>(l(),o("div",h,[d("div",g,[d("header",y,[e[0]||(e[0]=d("div",{class:"heading"},[d("p",null,"Analytics"),d("h2",null,"Website performance")],-1)),d("div",x,[(l(),o(c,null,u(B,a=>d("button",{key:a.value,type:"button",class:f({active:U.value===a.value}),onClick:e=>{return n=a.value,U.value=n,void ua();var n}},p(a.label),11,w)),64)),d("button",{class:"refresh",type:"button",disabled:V.value,onClick:ua},p(V.value?"Loading":"Refresh"),9,k)])]),V.value&&!q.value?(l(),o("div",A," Loading analytics... ")):Y.value?(l(),o("div",N,[e[1]||(e[1]=d("strong",null,"Could not load analytics",-1)),d("span",null,p(Y.value),1)])):(l(),o(c,{key:2},[d("section",L,[(l(!0),o(c,null,u(G.value,a=>(l(),o("article",{key:a.label,class:"kpi"},[d("span",null,p(a.label),1),d("strong",null,p(a.value),1)]))),128))]),d("section",$,[d("div",I,[d("div",null,[e[2]||(e[2]=d("h3",null,"Visitors & pageviews",-1)),d("p",null,p(W.value),1)]),e[3]||(e[3]=d("div",{class:"legend"},[d("span",null,[d("i",{class:"dot visitors"}),v("Visitors")]),d("span",null,[d("i",{class:"dot views"}),v("Pageviews")])],-1))]),d("div",j,[la.value.length>1?(l(),o("svg",z,[(l(),o(c,null,u(oa,a=>d("line",{key:a,x1:"0",x2:"700",y1:a,y2:a,class:"grid-line"},null,8,M)),64)),d("path",{d:ca("pageviews"),class:"area views-area"},null,8,S),d("path",{d:da("pageviews"),class:"line views-line"},null,8,T),d("path",{d:da("visitors"),class:"line visitors-line"},null,8,_),(l(!0),o(c,null,u(la.value,a=>(l(),o("circle",{key:`v-${a.index}`,cx:a.x,cy:a.visitorsY,r:"2.6",class:"circle visitors-circle"},null,8,C))),128))])):(l(),o("div",D,"Not enough chart data yet."))])]),d("section",E,[b(m(fa),{title:"Top pages",rows:K.value,empty:"No page data yet."},null,8,["rows"]),b(m(fa),{title:"Referrers",rows:Q.value,empty:"No referrer data yet.","fallback-label":"Direct"},null,8,["rows"]),b(m(fa),{title:"Countries",rows:X.value,empty:"No country data yet."},null,8,["rows"]),b(m(fa),{title:"Devices",rows:aa.value,empty:"No device data yet."},null,8,["rows"]),b(m(fa),{title:"Browsers",rows:ea.value,empty:"No browser data yet."},null,8,["rows"]),b(m(fa),{title:"Events",rows:na.value,empty:"No events yet."},null,8,["rows"])])],64))])]))}}),B=[],U=[];!function(a,e){if(a&&"undefined"!=typeof document){var n,t=!0===e.prepend?"prepend":"append",r=!0===e.singleTag,i="string"==typeof e.container?document.querySelector(e.container):document.getElementsByTagName("head")[0];if(r){var s=B.indexOf(i);-1===s&&(s=B.push(i)-1,U[s]={}),n=U[s]&&U[s][t]?U[s][t]:U[s][t]=l()}else n=l();65279===a.charCodeAt(0)&&(a=a.substring(1)),n.styleSheet?n.styleSheet.cssText+=a:n.appendChild(document.createTextNode(a))}function l(){var a=document.createElement("style");if(a.setAttribute("type","text/css"),e.attributes)for(var n=Object.keys(e.attributes),r=0;r<n.length;r++)a.setAttribute(n[r],e.attributes[n[r]]);var s="prepend"===t?"afterbegin":"beforeend";return i.insertAdjacentElement(s,a),a}}("\n.umami-panel[data-v-bf4a1aaf] {\n height: 100%;\n min-width: 0;\n overflow: hidden;\n background: var(--theme--background);\n color: var(--theme--foreground);\n}\n.panel-scroll[data-v-bf4a1aaf] {\n height: 100%;\n min-width: 0;\n overflow-y: auto;\n overflow-x: hidden;\n padding: 18px;\n scrollbar-width: thin;\n}\n.topbar[data-v-bf4a1aaf] {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 18px;\n margin-bottom: 18px;\n}\n.heading[data-v-bf4a1aaf] {\n min-width: 0;\n}\n.heading p[data-v-bf4a1aaf] {\n margin: 0 0 4px;\n color: var(--theme--foreground-subdued);\n font-size: 12px;\n font-weight: 700;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n}\n.heading h2[data-v-bf4a1aaf] {\n margin: 0;\n font-size: 22px;\n font-weight: 750;\n line-height: 1.15;\n}\n.controls[data-v-bf4a1aaf] {\n display: flex;\n flex: 0 0 auto;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 6px;\n}\n.controls button[data-v-bf4a1aaf] {\n border: var(--theme--border-width) solid var(--theme--border-color);\n border-radius: 999px;\n padding: 7px 11px;\n background: var(--theme--background-normal);\n color: var(--theme--foreground-subdued);\n cursor: pointer;\n font: inherit;\n font-size: 13px;\n line-height: 1;\n}\n.controls button.active[data-v-bf4a1aaf] {\n border-color: var(--theme--primary);\n background: var(--theme--primary-background);\n color: var(--theme--primary);\n}\n.controls button.refresh[data-v-bf4a1aaf] {\n color: var(--theme--foreground);\n}\n.controls button[data-v-bf4a1aaf]:disabled {\n cursor: wait;\n opacity: 0.55;\n}\n.kpis[data-v-bf4a1aaf] {\n display: flex;\n flex-wrap: wrap;\n gap: 12px;\n margin-bottom: 14px;\n}\n.kpi[data-v-bf4a1aaf] {\n flex: 1 1 150px;\n min-width: 0;\n border: var(--theme--border-width) solid var(--theme--border-color-subdued);\n border-radius: 18px;\n padding: 15px;\n background: var(--theme--background-normal);\n box-shadow: 0 1px 2px rgb(0 0 0 / 4%);\n}\n.kpi span[data-v-bf4a1aaf] {\n display: block;\n margin-bottom: 9px;\n color: var(--theme--foreground-subdued);\n font-size: 12px;\n}\n.kpi strong[data-v-bf4a1aaf] {\n display: block;\n overflow: hidden;\n font-size: clamp(22px, 5vw, 32px);\n font-weight: 780;\n line-height: 1;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.chart-card[data-v-bf4a1aaf],\n.metric-card[data-v-bf4a1aaf],\n.state[data-v-bf4a1aaf] {\n border: var(--theme--border-width) solid var(--theme--border-color-subdued);\n border-radius: 18px;\n background: var(--theme--background-normal);\n box-shadow: 0 1px 2px rgb(0 0 0 / 4%);\n}\n.chart-card[data-v-bf4a1aaf] {\n margin-bottom: 14px;\n padding: 15px;\n}\n.section-head[data-v-bf4a1aaf] {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 14px;\n margin-bottom: 12px;\n}\n.section-head h3[data-v-bf4a1aaf],\n.metric-card h3[data-v-bf4a1aaf] {\n margin: 0;\n font-size: 14px;\n font-weight: 750;\n}\n.section-head p[data-v-bf4a1aaf] {\n margin: 4px 0 0;\n color: var(--theme--foreground-subdued);\n font-size: 12px;\n}\n.legend[data-v-bf4a1aaf] {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 10px;\n color: var(--theme--foreground-subdued);\n font-size: 12px;\n}\n.legend span[data-v-bf4a1aaf] {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n}\n.dot[data-v-bf4a1aaf] {\n display: inline-block;\n width: 8px;\n height: 8px;\n border-radius: 999px;\n}\n.dot.visitors[data-v-bf4a1aaf] {\n background: var(--theme--primary);\n}\n.dot.views[data-v-bf4a1aaf] {\n background: var(--theme--foreground-subdued);\n}\n.chart-wrap[data-v-bf4a1aaf] {\n width: 100%;\n height: 260px;\n min-width: 0;\n overflow: hidden;\n}\nsvg[data-v-bf4a1aaf] {\n display: block;\n width: 100%;\n height: 100%;\n}\n.grid-line[data-v-bf4a1aaf] {\n stroke: var(--theme--border-color-subdued);\n stroke-width: 1;\n}\n.line[data-v-bf4a1aaf] {\n fill: none;\n stroke-width: 3;\n vector-effect: non-scaling-stroke;\n}\n.visitors-line[data-v-bf4a1aaf] {\n stroke: var(--theme--primary);\n}\n.views-line[data-v-bf4a1aaf] {\n stroke: var(--theme--foreground-subdued);\n opacity: 0.75;\n}\n.area[data-v-bf4a1aaf] {\n opacity: 0.08;\n}\n.views-area[data-v-bf4a1aaf] {\n fill: var(--theme--primary);\n}\n.circle[data-v-bf4a1aaf] {\n vector-effect: non-scaling-stroke;\n}\n.visitors-circle[data-v-bf4a1aaf] {\n fill: var(--theme--primary);\n}\n.lists[data-v-bf4a1aaf] {\n display: flex;\n flex-wrap: wrap;\n gap: 14px;\n align-items: stretch;\n}\n.metric-card[data-v-bf4a1aaf] {\n flex: 1 1 310px;\n min-width: 260px;\n overflow: hidden;\n}\n.metric-card header[data-v-bf4a1aaf] {\n border-bottom: var(--theme--border-width) solid var(--theme--border-color-subdued);\n padding: 13px 15px;\n}\n.metric-card ul[data-v-bf4a1aaf] {\n list-style: none;\n margin: 0;\n padding: 4px 0;\n}\n.metric-card li[data-v-bf4a1aaf] {\n display: flex;\n align-items: center;\n gap: 10px;\n min-width: 0;\n padding: 9px 15px;\n}\n.metric-card li + li[data-v-bf4a1aaf] {\n border-top: 1px solid var(--theme--border-color-subdued);\n}\n.metric-label[data-v-bf4a1aaf] {\n flex: 1 1 140px;\n min-width: 0;\n overflow: hidden;\n font-size: 13px;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.metric-bar[data-v-bf4a1aaf] {\n flex: 1 1 90px;\n min-width: 42px;\n max-width: 180px;\n height: 6px;\n overflow: hidden;\n border-radius: 999px;\n background: var(--theme--background-accent);\n}\n.metric-bar span[data-v-bf4a1aaf] {\n display: block;\n height: 100%;\n border-radius: inherit;\n background: var(--theme--primary);\n}\n.metric-card b[data-v-bf4a1aaf] {\n flex: 0 0 auto;\n min-width: 40px;\n font-size: 13px;\n text-align: right;\n}\n.empty[data-v-bf4a1aaf],\n.empty-chart[data-v-bf4a1aaf] {\n margin: 0;\n padding: 20px 15px;\n color: var(--theme--foreground-subdued);\n font-size: 13px;\n}\n.empty-chart[data-v-bf4a1aaf] {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n}\n.state[data-v-bf4a1aaf] {\n padding: 18px;\n color: var(--theme--foreground-subdued);\n}\n.state.error[data-v-bf4a1aaf] {\n display: flex;\n flex-direction: column;\n gap: 6px;\n color: var(--theme--danger);\n}\n@media (max-width: 760px) {\n.topbar[data-v-bf4a1aaf],\n .section-head[data-v-bf4a1aaf] {\n flex-direction: column;\n align-items: stretch;\n}\n.controls[data-v-bf4a1aaf] {\n justify-content: flex-start;\n}\n.controls button[data-v-bf4a1aaf] {\n flex: 1 1 auto;\n}\n}\n@media (max-width: 520px) {\n.panel-scroll[data-v-bf4a1aaf] {\n padding: 12px;\n}\n.kpi[data-v-bf4a1aaf],\n .metric-card[data-v-bf4a1aaf] {\n flex-basis: 100%;\n min-width: 0;\n}\n.chart-wrap[data-v-bf4a1aaf] {\n height: 220px;\n}\n.metric-card li[data-v-bf4a1aaf] {\n flex-wrap: wrap;\n}\n.metric-label[data-v-bf4a1aaf] {\n flex-basis: calc(100% - 52px);\n}\n.metric-bar[data-v-bf4a1aaf] {\n order: 3;\n flex-basis: 100%;\n max-width: none;\n}\n}\n",{});const V=[],Y=[],q=[],O=[],W=[a({id:"umami-analytics-panel",name:"Umami Analytics",icon:"monitoring",description:"Native Directus panel for Umami visitors, pageviews, top pages, referrers, and events.",component:((a,e)=>{const n=a.__vccOpts||a;for(const[a,t]of e)n[a]=t;return n})(R,[["__scopeId","data-v-bf4a1aaf"]]),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})],P=[],F=[];export{Y as displays,V as interfaces,q as layouts,O as modules,F as operations,W as panels,P as themes};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@koendhoore/directus-extension-umami-analytics",
3
- "version": "1.0.3",
4
- "description": "Directus bundle: secure Umami proxy endpoint + native Insights analytics panel.",
3
+ "version": "2.0.0",
4
+ "description": "Directus Insights panel and endpoint for Umami analytics.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "scripts": {
@@ -17,7 +17,6 @@ function required(env: Env, key: string): string {
17
17
  }
18
18
 
19
19
  function cleanBaseUrl(env: Env): string {
20
- // Accept both https://analytics.example.com and https://analytics.example.com/api
21
20
  return required(env, 'UMAMI_URL').replace(/\/api\/?$/, '');
22
21
  }
23
22
 
@@ -33,10 +32,31 @@ function numberParam(value: unknown, fallback: number) {
33
32
 
34
33
  function getRange(query: Record<string, unknown>) {
35
34
  const now = Date.now();
36
- const days = Math.max(1, Math.min(365, numberParam(query.days, 30)));
35
+ const preset = String(query.preset || query.range || '').toLowerCase();
36
+
37
+ if (preset === 'today') {
38
+ const start = new Date();
39
+ start.setHours(0, 0, 0, 0);
40
+ return { startAt: start.getTime(), endAt: now, days: 1, preset };
41
+ }
42
+
43
+ const presetDays: Record<string, number> = {
44
+ '7d': 7,
45
+ 'last7': 7,
46
+ 'last_7_days': 7,
47
+ '30d': 30,
48
+ 'last30': 30,
49
+ 'last_30_days': 30,
50
+ '90d': 90,
51
+ 'last90': 90,
52
+ 'last_90_days': 90,
53
+ };
54
+
55
+ const days = Math.max(1, Math.min(365, numberParam(query.days, presetDays[preset] || 30)));
37
56
  const endAt = numberParam(query.endAt, now);
38
57
  const startAt = numberParam(query.startAt, endAt - days * 24 * 60 * 60 * 1000);
39
- return { startAt, endAt, days };
58
+
59
+ return { startAt, endAt, days, preset: preset || `${days}d` };
40
60
  }
41
61
 
42
62
  function cached<T>(key: string): T | null {
@@ -86,7 +106,6 @@ function getApiClientAuth(env: Env): UmamiAuth | null {
86
106
 
87
107
  if (!userId || !appSecret) return null;
88
108
 
89
- // This matches @umami/api-client: secret is sha512(APP_SECRET), then createSecureToken({ userId }, secret)
90
109
  const secret = hash(appSecret);
91
110
  const token = createSecureToken({ userId }, secret);
92
111
 
@@ -160,6 +179,14 @@ async function umamiGet(env: Env, path: string) {
160
179
  return text ? JSON.parse(text) : null;
161
180
  }
162
181
 
182
+ async function safeUmamiGet(env: Env, path: string, fallback: unknown = []) {
183
+ try {
184
+ return await umamiGet(env, path);
185
+ } catch (error: any) {
186
+ return fallback;
187
+ }
188
+ }
189
+
163
190
  function metricPath(websiteId: string, type: string, startAt: number, endAt: number, limit = 10) {
164
191
  const params = new URLSearchParams({
165
192
  startAt: String(Math.round(startAt)),
@@ -170,62 +197,41 @@ function metricPath(websiteId: string, type: string, startAt: number, endAt: num
170
197
  return `/websites/${websiteId}/metrics?${params.toString()}`;
171
198
  }
172
199
 
173
- async function safeUmamiGet(env: Env, path: string, fallback: unknown = []) {
174
- try {
175
- return await umamiGet(env, path);
176
- } catch (error: any) {
177
- return {
178
- error: error.message || 'Unknown Umami error',
179
- path,
180
- fallback,
181
- };
182
- }
200
+ function pageviewsPath(websiteId: string, startAt: number, endAt: number, unit: string) {
201
+ const params = new URLSearchParams({
202
+ startAt: String(Math.round(startAt)),
203
+ endAt: String(Math.round(endAt)),
204
+ unit,
205
+ });
206
+ return `/websites/${websiteId}/pageviews?${params.toString()}`;
207
+ }
208
+
209
+ function unitForDays(days: number) {
210
+ if (days <= 2) return 'hour';
211
+ if (days <= 90) return 'day';
212
+ return 'month';
183
213
  }
184
214
 
185
215
  export default (router: any, context: any) => {
186
216
  const env: Env = context.env || process.env;
187
217
 
188
218
  router.get('/health', (_req: any, res: any) => {
189
- res.json({ ok: true, service: 'umami-analytics' });
219
+ res.json({ ok: true, service: 'umami-analytics', version: '2.0.0' });
190
220
  });
191
221
 
192
- router.get('/debug', async (_req: any, res: any) => {
193
- try {
194
- const authMode = env.UMAMI_API_KEY
195
- ? 'api-key'
196
- : env.UMAMI_TOKEN
197
- ? 'bearer-token'
198
- : env.UMAMI_USER_ID || env.UMAMI_API_CLIENT_USER_ID
199
- ? 'api-client'
200
- : 'username-password';
201
-
202
- const me = await umamiGet(env, '/me').catch((error: any) => ({ error: error.message }));
203
-
204
- res.json({
205
- ok: true,
206
- authMode,
207
- umamiUrl: cleanBaseUrl(env),
208
- websiteId: env.UMAMI_WEBSITE_ID,
209
- me,
210
- });
211
- } catch (error: any) {
212
- res.status(500).json({ error: error.message || 'Unknown error' });
213
- }
214
- });
222
+
215
223
 
216
224
  router.get('/summary', async (req: any, res: any) => {
217
225
  try {
218
226
  const websiteId = String(req.query.websiteId || env.UMAMI_WEBSITE_ID || '');
219
227
  if (!websiteId) return res.status(400).json({ error: 'Missing websiteId or UMAMI_WEBSITE_ID' });
220
228
 
221
- const { startAt, endAt, days } = getRange(req.query);
222
- const key = `summary:${websiteId}:${startAt}:${endAt}:v3`;
229
+ const { startAt, endAt, days, preset } = getRange(req.query);
230
+ const unit = String(req.query.unit || unitForDays(days));
231
+ const key = `summary:v2:${websiteId}:${startAt}:${endAt}:${unit}`;
223
232
  const hit = cached(key);
224
233
  if (hit) return res.json(hit);
225
234
 
226
- // Umami v3:
227
- // - /stats accepts startAt/endAt only.
228
- // - /metrics uses type=path, not type=url.
229
235
  const statsParams = new URLSearchParams({
230
236
  startAt: String(Math.round(startAt)),
231
237
  endAt: String(Math.round(endAt)),
@@ -233,13 +239,31 @@ export default (router: any, context: any) => {
233
239
 
234
240
  const stats = await umamiGet(env, `/websites/${websiteId}/stats?${statsParams.toString()}`);
235
241
 
236
- const [pages, referrers, events] = await Promise.all([
237
- safeUmamiGet(env, metricPath(websiteId, 'path', startAt, endAt, 10), []),
238
- safeUmamiGet(env, metricPath(websiteId, 'referrer', startAt, endAt, 10), []),
239
- safeUmamiGet(env, metricPath(websiteId, 'event', startAt, endAt, 10), []),
242
+ const [pageviews, pages, referrers, countries, browsers, os, devices, events] = await Promise.all([
243
+ safeUmamiGet(env, pageviewsPath(websiteId, startAt, endAt, unit), { pageviews: [], sessions: [] }),
244
+ safeUmamiGet(env, metricPath(websiteId, 'path', startAt, endAt, 12), []),
245
+ safeUmamiGet(env, metricPath(websiteId, 'referrer', startAt, endAt, 12), []),
246
+ safeUmamiGet(env, metricPath(websiteId, 'country', startAt, endAt, 12), []),
247
+ safeUmamiGet(env, metricPath(websiteId, 'browser', startAt, endAt, 12), []),
248
+ safeUmamiGet(env, metricPath(websiteId, 'os', startAt, endAt, 12), []),
249
+ safeUmamiGet(env, metricPath(websiteId, 'device', startAt, endAt, 12), []),
250
+ safeUmamiGet(env, metricPath(websiteId, 'event', startAt, endAt, 12), []),
240
251
  ]);
241
252
 
242
- const payload = { websiteId, range: { startAt, endAt, days }, stats, pages, referrers, events };
253
+ const payload = {
254
+ websiteId,
255
+ range: { startAt, endAt, days, preset, unit },
256
+ stats,
257
+ pageviews,
258
+ pages,
259
+ referrers,
260
+ countries,
261
+ browsers,
262
+ os,
263
+ devices,
264
+ events,
265
+ };
266
+
243
267
  setCached(key, payload);
244
268
  res.json(payload);
245
269
  } catch (error: any) {
@@ -1,75 +1,130 @@
1
1
  <template>
2
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>
3
+ <div class="panel-scroll">
4
+ <header class="topbar">
5
+ <div class="heading">
6
+ <p>Analytics</p>
7
+ <h2>Website performance</h2>
10
8
  </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>
9
+
10
+ <div class="controls">
11
+ <button
12
+ v-for="option in ranges"
13
+ :key="option.value"
14
+ type="button"
15
+ :class="{ active: selectedRange === option.value }"
16
+ @click="setRange(option.value)"
17
+ >
18
+ {{ option.label }}
19
+ </button>
20
+
21
+ <button class="refresh" type="button" :disabled="loading" @click="load">
22
+ {{ loading ? 'Loading' : 'Refresh' }}
23
+ </button>
22
24
  </div>
25
+ </header>
26
+
27
+ <div v-if="loading && !data" class="state">
28
+ Loading analytics...
29
+ </div>
30
+
31
+ <div v-else-if="error" class="state error">
32
+ <strong>Could not load analytics</strong>
33
+ <span>{{ error }}</span>
23
34
  </div>
24
35
 
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>
36
+ <template v-else>
37
+ <section class="kpis">
38
+ <article v-for="card in kpiCards" :key="card.label" class="kpi">
39
+ <span>{{ card.label }}</span>
40
+ <strong>{{ card.value }}</strong>
41
+ </article>
34
42
  </section>
35
43
 
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 class="chart-card">
45
+ <div class="section-head">
46
+ <div>
47
+ <h3>Visitors & pageviews</h3>
48
+ <p>{{ activeRangeLabel }}</p>
49
+ </div>
50
+
51
+ <div class="legend">
52
+ <span><i class="dot visitors"></i>Visitors</span>
53
+ <span><i class="dot views"></i>Pageviews</span>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="chart-wrap">
58
+ <svg v-if="chartPoints.length > 1" viewBox="0 0 700 260" preserveAspectRatio="none" role="img">
59
+ <line
60
+ v-for="tick in yTicks"
61
+ :key="tick"
62
+ x1="0"
63
+ x2="700"
64
+ :y1="tick"
65
+ :y2="tick"
66
+ class="grid-line"
67
+ />
68
+
69
+ <path :d="areaPath('pageviews')" class="area views-area"></path>
70
+ <path :d="linePath('pageviews')" class="line views-line"></path>
71
+ <path :d="linePath('visitors')" class="line visitors-line"></path>
72
+
73
+ <circle
74
+ v-for="point in chartPoints"
75
+ :key="`v-${point.index}`"
76
+ :cx="point.x"
77
+ :cy="point.visitorsY"
78
+ r="2.6"
79
+ class="circle visitors-circle"
80
+ />
81
+ </svg>
82
+
83
+ <div v-else class="empty-chart">Not enough chart data yet.</div>
84
+ </div>
44
85
  </section>
45
86
 
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>
87
+ <section class="lists">
88
+ <metric-list title="Top pages" :rows="pages" empty="No page data yet." />
89
+ <metric-list title="Referrers" :rows="referrers" empty="No referrer data yet." fallback-label="Direct" />
90
+ <metric-list title="Countries" :rows="countries" empty="No country data yet." />
91
+ <metric-list title="Devices" :rows="devices" empty="No device data yet." />
92
+ <metric-list title="Browsers" :rows="browsers" empty="No browser data yet." />
93
+ <metric-list title="Events" :rows="events" empty="No events yet." />
54
94
  </section>
55
- </div>
56
- </template>
95
+ </template>
96
+ </div>
57
97
  </div>
58
98
  </template>
59
99
 
60
100
  <script setup lang="ts">
61
- import { computed, onMounted, ref, watch } from 'vue';
101
+ import { computed, defineComponent, h, onMounted, ref, watch } from 'vue';
62
102
 
63
103
  const props = defineProps<{
64
104
  websiteId?: string;
65
105
  days?: number;
66
106
  }>();
67
107
 
108
+ const ranges = [
109
+ { label: 'Today', value: 'today', days: 1 },
110
+ { label: '7d', value: '7d', days: 7 },
111
+ { label: '30d', value: '30d', days: 30 },
112
+ { label: '90d', value: '90d', days: 90 },
113
+ { label: 'All', value: '365d', days: 365 },
114
+ ];
115
+
116
+ const selectedRange = ref(props.days ? `${props.days}d` : '30d');
68
117
  const loading = ref(true);
69
118
  const error = ref('');
70
119
  const data = ref<any>(null);
71
120
 
72
- const days = computed(() => props.days || 30);
121
+ const activeRange = computed(() => ranges.find((range) => range.value === selectedRange.value) || ranges[2]);
122
+ const activeRangeLabel = computed(() => activeRange.value.label === 'All' ? 'Last 365 days' : activeRange.value.label === 'Today' ? 'Today' : `Last ${activeRange.value.days} days`);
123
+
124
+ function setRange(value: string) {
125
+ selectedRange.value = value;
126
+ load();
127
+ }
73
128
 
74
129
  function valueOfMetric(metric: any) {
75
130
  if (metric == null) return 0;
@@ -78,26 +133,129 @@ function valueOfMetric(metric: any) {
78
133
  return Number(metric.value || metric.count || metric.y || 0);
79
134
  }
80
135
 
136
+ function formatNumber(value: number) {
137
+ return new Intl.NumberFormat().format(Math.round(Number(value || 0)));
138
+ }
139
+
81
140
  function stat(key: string) {
82
- const value = valueOfMetric(data.value?.stats?.[key]);
83
- return new Intl.NumberFormat().format(value);
141
+ return formatNumber(valueOfMetric(data.value?.stats?.[key]));
84
142
  }
85
143
 
86
144
  function percentStat(key: string) {
87
145
  const value = valueOfMetric(data.value?.stats?.[key]);
88
146
  if (!value) return '0%';
89
- return `${Math.round(value * 100) / 100}%`;
147
+ return `${Math.round(value * 10) / 10}%`;
90
148
  }
91
149
 
92
- const pages = computed(() => data.value?.pages || []);
93
- const referrers = computed(() => data.value?.referrers || []);
94
- const events = computed(() => data.value?.events || []);
150
+ const kpiCards = computed(() => [
151
+ { label: 'Visitors', value: stat('visitors') },
152
+ { label: 'Visits', value: stat('visits') },
153
+ { label: 'Pageviews', value: stat('pageviews') },
154
+ { label: 'Bounce rate', value: percentStat('bounces') },
155
+ { label: 'Visit duration', value: `${formatNumber(valueOfMetric(data.value?.stats?.totaltime))}s` },
156
+ ]);
157
+
158
+ function asRows(value: any) {
159
+ return Array.isArray(value) ? value.filter((row) => !row?.error) : [];
160
+ }
161
+
162
+ const pages = computed(() => asRows(data.value?.pages));
163
+ const referrers = computed(() => asRows(data.value?.referrers));
164
+ const countries = computed(() => asRows(data.value?.countries));
165
+ const devices = computed(() => asRows(data.value?.devices));
166
+ const browsers = computed(() => asRows(data.value?.browsers));
167
+ const events = computed(() => asRows(data.value?.events));
168
+
169
+ function normalizeSeries(value: any) {
170
+ if (Array.isArray(value)) return value;
171
+ if (Array.isArray(value?.pageviews)) return value.pageviews;
172
+ if (Array.isArray(value?.views)) return value.views;
173
+ if (Array.isArray(value?.data)) return value.data;
174
+ return [];
175
+ }
176
+
177
+ function rowDate(row: any) {
178
+ return row?.x || row?.date || row?.t || row?.createdAt || row?.day || '';
179
+ }
180
+
181
+ function rowViews(row: any) {
182
+ return Number(row?.y || row?.pageviews || row?.views || row?.value || row?.count || 0);
183
+ }
184
+
185
+ function rowVisitors(row: any) {
186
+ return Number(row?.visitors || row?.sessions || row?.uniques || row?.users || rowViews(row));
187
+ }
188
+
189
+ const chartRaw = computed(() => {
190
+ const root = data.value?.pageviews;
191
+ const merged = Array.isArray(root?.pageviews)
192
+ ? root.pageviews.map((row: any, index: number) => ({
193
+ date: rowDate(row),
194
+ pageviews: rowViews(row),
195
+ visitors: rowVisitors(root.sessions?.[index] || row),
196
+ }))
197
+ : normalizeSeries(root).map((row: any) => ({
198
+ date: rowDate(row),
199
+ pageviews: rowViews(row),
200
+ visitors: rowVisitors(row),
201
+ }));
202
+
203
+ return merged.filter((row: any) => row.pageviews || row.visitors);
204
+ });
205
+
206
+ const chartPoints = computed(() => {
207
+ const rows = chartRaw.value;
208
+ const width = 700;
209
+ const height = 220;
210
+ const top = 18;
211
+ const max = Math.max(...rows.map((row: any) => Math.max(row.pageviews, row.visitors)), 1);
212
+
213
+ return rows.map((row: any, index: number) => {
214
+ const x = rows.length === 1 ? width / 2 : (index / (rows.length - 1)) * width;
215
+ const pageviewsY = top + height - (row.pageviews / max) * height;
216
+ const visitorsY = top + height - (row.visitors / max) * height;
217
+
218
+ return {
219
+ ...row,
220
+ index,
221
+ x,
222
+ pageviewsY,
223
+ visitorsY,
224
+ };
225
+ });
226
+ });
227
+
228
+ const yTicks = [40, 95, 150, 205];
229
+
230
+ function linePath(type: 'pageviews' | 'visitors') {
231
+ const points = chartPoints.value;
232
+ if (!points.length) return '';
233
+ const yKey = type === 'pageviews' ? 'pageviewsY' : 'visitorsY';
234
+ return points.map((point: any, index: number) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point[yKey]}`).join(' ');
235
+ }
236
+
237
+ function areaPath(type: 'pageviews' | 'visitors') {
238
+ const points = chartPoints.value;
239
+ if (!points.length) return '';
240
+ const line = linePath(type);
241
+ const last = points[points.length - 1];
242
+ const first = points[0];
243
+ return `${line} L ${last.x} 240 L ${first.x} 240 Z`;
244
+ }
95
245
 
96
246
  async function load() {
97
247
  loading.value = true;
98
248
  error.value = '';
99
249
 
100
- const params = new URLSearchParams({ days: String(days.value) });
250
+ const params = new URLSearchParams();
251
+ const range = activeRange.value;
252
+
253
+ if (range.value === 'today') {
254
+ params.set('preset', 'today');
255
+ } else {
256
+ params.set('days', String(range.days));
257
+ }
258
+
101
259
  if (props.websiteId) params.set('websiteId', props.websiteId);
102
260
 
103
261
  try {
@@ -116,78 +274,429 @@ async function load() {
116
274
  }
117
275
  }
118
276
 
277
+ const MetricList = defineComponent({
278
+ name: 'MetricList',
279
+ props: {
280
+ title: { type: String, required: true },
281
+ rows: { type: Array, default: () => [] },
282
+ empty: { type: String, default: 'No data yet.' },
283
+ fallbackLabel: { type: String, default: '(unknown)' },
284
+ },
285
+ setup(listProps) {
286
+ function label(row: any) {
287
+ return String(row?.x || row?.name || row?.title || row?.label || listProps.fallbackLabel);
288
+ }
289
+
290
+ function rawCount(row: any) {
291
+ return Number(row?.y || row?.value || row?.count || 0);
292
+ }
293
+
294
+ function count(row: any) {
295
+ return new Intl.NumberFormat().format(rawCount(row));
296
+ }
297
+
298
+ function barWidth(row: any) {
299
+ const max = Math.max(...(listProps.rows as any[]).map(rawCount), 1);
300
+ const percentage = Math.max(5, Math.round((rawCount(row) / max) * 100));
301
+ return `${percentage}%`;
302
+ }
303
+
304
+ return () =>
305
+ h('article', { class: 'metric-card' }, [
306
+ h('header', [h('h3', listProps.title)]),
307
+ (listProps.rows as any[]).length
308
+ ? h(
309
+ 'ul',
310
+ (listProps.rows as any[]).map((row: any) =>
311
+ h('li', { key: label(row) }, [
312
+ h('span', { class: 'metric-label', title: label(row) }, label(row)),
313
+ h('span', { class: 'metric-bar', 'aria-hidden': 'true' }, [
314
+ h('span', { style: { width: barWidth(row) } }),
315
+ ]),
316
+ h('b', count(row)),
317
+ ])
318
+ )
319
+ )
320
+ : h('p', { class: 'empty' }, listProps.empty),
321
+ ]);
322
+ },
323
+ });
324
+
119
325
  onMounted(load);
120
- watch(() => [props.websiteId, props.days], load);
326
+ watch(() => [props.websiteId, props.days], () => {
327
+ if (props.days) selectedRange.value = `${props.days}d`;
328
+ load();
329
+ });
121
330
  </script>
122
331
 
123
332
  <style scoped>
124
333
  .umami-panel {
125
- padding: 16px;
126
334
  height: 100%;
127
- overflow: auto;
335
+ min-width: 0;
336
+ overflow: hidden;
337
+ background: var(--theme--background);
338
+ color: var(--theme--foreground);
128
339
  }
129
- .state {
130
- padding: 16px;
340
+
341
+ .panel-scroll {
342
+ height: 100%;
343
+ min-width: 0;
344
+ overflow-y: auto;
345
+ overflow-x: hidden;
346
+ padding: 18px;
347
+ scrollbar-width: thin;
348
+ }
349
+
350
+ .topbar {
351
+ display: flex;
352
+ align-items: flex-start;
353
+ justify-content: space-between;
354
+ gap: 18px;
355
+ margin-bottom: 18px;
356
+ }
357
+
358
+ .heading {
359
+ min-width: 0;
360
+ }
361
+
362
+ .heading p {
363
+ margin: 0 0 4px;
131
364
  color: var(--theme--foreground-subdued);
365
+ font-size: 12px;
366
+ font-weight: 700;
367
+ letter-spacing: 0.04em;
368
+ text-transform: uppercase;
132
369
  }
133
- .error {
134
- color: var(--theme--danger);
370
+
371
+ .heading h2 {
372
+ margin: 0;
373
+ font-size: 22px;
374
+ font-weight: 750;
375
+ line-height: 1.15;
135
376
  }
136
- .cards {
137
- display: grid;
138
- grid-template-columns: repeat(4, minmax(0, 1fr));
139
- gap: 12px;
140
- margin-bottom: 18px;
377
+
378
+ .controls {
379
+ display: flex;
380
+ flex: 0 0 auto;
381
+ flex-wrap: wrap;
382
+ justify-content: flex-end;
383
+ gap: 6px;
141
384
  }
142
- .card {
385
+
386
+ .controls button {
143
387
  border: var(--theme--border-width) solid var(--theme--border-color);
144
- border-radius: var(--theme--border-radius);
145
- padding: 14px;
388
+ border-radius: 999px;
389
+ padding: 7px 11px;
390
+ background: var(--theme--background-normal);
391
+ color: var(--theme--foreground-subdued);
392
+ cursor: pointer;
393
+ font: inherit;
394
+ font-size: 13px;
395
+ line-height: 1;
396
+ }
397
+
398
+ .controls button.active {
399
+ border-color: var(--theme--primary);
400
+ background: var(--theme--primary-background);
401
+ color: var(--theme--primary);
402
+ }
403
+
404
+ .controls button.refresh {
405
+ color: var(--theme--foreground);
406
+ }
407
+
408
+ .controls button:disabled {
409
+ cursor: wait;
410
+ opacity: 0.55;
411
+ }
412
+
413
+ .kpis {
414
+ display: flex;
415
+ flex-wrap: wrap;
416
+ gap: 12px;
417
+ margin-bottom: 14px;
418
+ }
419
+
420
+ .kpi {
421
+ flex: 1 1 150px;
422
+ min-width: 0;
423
+ border: var(--theme--border-width) solid var(--theme--border-color-subdued);
424
+ border-radius: 18px;
425
+ padding: 15px;
146
426
  background: var(--theme--background-normal);
427
+ box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
147
428
  }
148
- .card span {
429
+
430
+ .kpi span {
149
431
  display: block;
432
+ margin-bottom: 9px;
150
433
  color: var(--theme--foreground-subdued);
151
434
  font-size: 12px;
152
- margin-bottom: 6px;
153
435
  }
154
- .card strong {
155
- font-size: 24px;
436
+
437
+ .kpi strong {
438
+ display: block;
439
+ overflow: hidden;
440
+ font-size: clamp(22px, 5vw, 32px);
441
+ font-weight: 780;
156
442
  line-height: 1;
443
+ text-overflow: ellipsis;
444
+ white-space: nowrap;
157
445
  }
158
- .grid {
159
- display: grid;
160
- grid-template-columns: repeat(3, minmax(0, 1fr));
161
- gap: 18px;
446
+
447
+ .chart-card,
448
+ .metric-card,
449
+ .state {
450
+ border: var(--theme--border-width) solid var(--theme--border-color-subdued);
451
+ border-radius: 18px;
452
+ background: var(--theme--background-normal);
453
+ box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
454
+ }
455
+
456
+ .chart-card {
457
+ margin-bottom: 14px;
458
+ padding: 15px;
459
+ }
460
+
461
+ .section-head {
462
+ display: flex;
463
+ align-items: flex-start;
464
+ justify-content: space-between;
465
+ gap: 14px;
466
+ margin-bottom: 12px;
162
467
  }
163
- h3 {
164
- margin: 0 0 8px;
468
+
469
+ .section-head h3,
470
+ .metric-card h3 {
471
+ margin: 0;
165
472
  font-size: 14px;
473
+ font-weight: 750;
474
+ }
475
+
476
+ .section-head p {
477
+ margin: 4px 0 0;
478
+ color: var(--theme--foreground-subdued);
479
+ font-size: 12px;
480
+ }
481
+
482
+ .legend {
483
+ display: flex;
484
+ flex-wrap: wrap;
485
+ justify-content: flex-end;
486
+ gap: 10px;
487
+ color: var(--theme--foreground-subdued);
488
+ font-size: 12px;
166
489
  }
167
- ul {
490
+
491
+ .legend span {
492
+ display: inline-flex;
493
+ align-items: center;
494
+ gap: 5px;
495
+ }
496
+
497
+ .dot {
498
+ display: inline-block;
499
+ width: 8px;
500
+ height: 8px;
501
+ border-radius: 999px;
502
+ }
503
+
504
+ .dot.visitors {
505
+ background: var(--theme--primary);
506
+ }
507
+
508
+ .dot.views {
509
+ background: var(--theme--foreground-subdued);
510
+ }
511
+
512
+ .chart-wrap {
513
+ width: 100%;
514
+ height: 260px;
515
+ min-width: 0;
516
+ overflow: hidden;
517
+ }
518
+
519
+ svg {
520
+ display: block;
521
+ width: 100%;
522
+ height: 100%;
523
+ }
524
+
525
+ .grid-line {
526
+ stroke: var(--theme--border-color-subdued);
527
+ stroke-width: 1;
528
+ }
529
+
530
+ .line {
531
+ fill: none;
532
+ stroke-width: 3;
533
+ vector-effect: non-scaling-stroke;
534
+ }
535
+
536
+ .visitors-line {
537
+ stroke: var(--theme--primary);
538
+ }
539
+
540
+ .views-line {
541
+ stroke: var(--theme--foreground-subdued);
542
+ opacity: 0.75;
543
+ }
544
+
545
+ .area {
546
+ opacity: 0.08;
547
+ }
548
+
549
+ .views-area {
550
+ fill: var(--theme--primary);
551
+ }
552
+
553
+ .circle {
554
+ vector-effect: non-scaling-stroke;
555
+ }
556
+
557
+ .visitors-circle {
558
+ fill: var(--theme--primary);
559
+ }
560
+
561
+ .lists {
562
+ display: flex;
563
+ flex-wrap: wrap;
564
+ gap: 14px;
565
+ align-items: stretch;
566
+ }
567
+
568
+ .metric-card {
569
+ flex: 1 1 310px;
570
+ min-width: 260px;
571
+ overflow: hidden;
572
+ }
573
+
574
+ .metric-card header {
575
+ border-bottom: var(--theme--border-width) solid var(--theme--border-color-subdued);
576
+ padding: 13px 15px;
577
+ }
578
+
579
+ .metric-card ul {
168
580
  list-style: none;
169
581
  margin: 0;
170
- padding: 0;
582
+ padding: 4px 0;
171
583
  }
172
- li {
584
+
585
+ .metric-card li {
173
586
  display: flex;
174
- justify-content: space-between;
175
- gap: 12px;
176
- padding: 7px 0;
177
- border-bottom: 1px solid var(--theme--border-color-subdued);
587
+ align-items: center;
588
+ gap: 10px;
589
+ min-width: 0;
590
+ padding: 9px 15px;
591
+ }
592
+
593
+ .metric-card li + li {
594
+ border-top: 1px solid var(--theme--border-color-subdued);
178
595
  }
179
- li span {
596
+
597
+ .metric-label {
598
+ flex: 1 1 140px;
599
+ min-width: 0;
180
600
  overflow: hidden;
601
+ font-size: 13px;
181
602
  text-overflow: ellipsis;
182
603
  white-space: nowrap;
183
604
  }
184
- li b {
605
+
606
+ .metric-bar {
607
+ flex: 1 1 90px;
608
+ min-width: 42px;
609
+ max-width: 180px;
610
+ height: 6px;
611
+ overflow: hidden;
612
+ border-radius: 999px;
613
+ background: var(--theme--background-accent);
614
+ }
615
+
616
+ .metric-bar span {
617
+ display: block;
618
+ height: 100%;
619
+ border-radius: inherit;
620
+ background: var(--theme--primary);
621
+ }
622
+
623
+ .metric-card b {
185
624
  flex: 0 0 auto;
625
+ min-width: 40px;
626
+ font-size: 13px;
627
+ text-align: right;
628
+ }
629
+
630
+ .empty,
631
+ .empty-chart {
632
+ margin: 0;
633
+ padding: 20px 15px;
634
+ color: var(--theme--foreground-subdued);
635
+ font-size: 13px;
636
+ }
637
+
638
+ .empty-chart {
639
+ display: flex;
640
+ align-items: center;
641
+ justify-content: center;
642
+ height: 100%;
643
+ }
644
+
645
+ .state {
646
+ padding: 18px;
647
+ color: var(--theme--foreground-subdued);
648
+ }
649
+
650
+ .state.error {
651
+ display: flex;
652
+ flex-direction: column;
653
+ gap: 6px;
654
+ color: var(--theme--danger);
186
655
  }
187
- @media (max-width: 900px) {
188
- .cards,
189
- .grid {
190
- grid-template-columns: 1fr;
656
+
657
+ @media (max-width: 760px) {
658
+ .topbar,
659
+ .section-head {
660
+ flex-direction: column;
661
+ align-items: stretch;
662
+ }
663
+
664
+ .controls {
665
+ justify-content: flex-start;
666
+ }
667
+
668
+ .controls button {
669
+ flex: 1 1 auto;
670
+ }
671
+ }
672
+
673
+ @media (max-width: 520px) {
674
+ .panel-scroll {
675
+ padding: 12px;
676
+ }
677
+
678
+ .kpi,
679
+ .metric-card {
680
+ flex-basis: 100%;
681
+ min-width: 0;
682
+ }
683
+
684
+ .chart-wrap {
685
+ height: 220px;
686
+ }
687
+
688
+ .metric-card li {
689
+ flex-wrap: wrap;
690
+ }
691
+
692
+ .metric-label {
693
+ flex-basis: calc(100% - 52px);
694
+ }
695
+
696
+ .metric-bar {
697
+ order: 3;
698
+ flex-basis: 100%;
699
+ max-width: none;
191
700
  }
192
701
  }
193
702
  </style>