@koendhoore/directus-extension-umami-analytics 1.0.2 → 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";function e(t,e){const r=e.replace(/^\/api\/?/,"").replace(/^\/+/,"");return`${n(t)}/api/${r}`}function r(t){return Buffer.from(t).toString("base64").replace(/=/g,"").replace(/\+/g,"-").replace(/\//g,"_")}function n(t){return c(t,"UMAMI_URL").replace(/\/api\/?$/,"")}function a(e,n){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,n){const a=`${r(JSON.stringify({alg:"HS256",typ:"JWT"}))}.${r(JSON.stringify(e))}`;return`${a}.${r(t.createHmac("sha256",n).update(a).digest())}`}(e,n),n)}function s(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 ${a({userId:r},function(...e){return t.createHash("sha512").update(e.join("")).digest("hex")}(n))}`}}}async function o(t){const r=t.UMAMI_API_KEY;if(r)return{headers:{"x-umami-api-key":r}};const n=t.UMAMI_TOKEN;if(n)return{headers:{Authorization:`Bearer ${n}`}};const a=s(t);return a||async function(t){if(M&&M.expires>Date.now())return M.auth;const r=c(t,"UMAMI_USERNAME"),n=c(t,"UMAMI_PASSWORD"),a=await fetch(e(t,"/auth/login"),{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({username:r,password:n})}),s=await a.text();if(!a.ok)throw new Error(`Umami login failed: ${a.status} ${s}`);let o=null;try{o=s?JSON.parse(s):null}catch{}const i=o?.token||o?.accessToken||o?.data?.token||o?.data?.accessToken,u=a.headers.get("set-cookie")||"",d=i?{headers:{Authorization:`Bearer ${i}`}}:{headers:u?{Cookie:u.split(";")[0]}:{}};return M={expires:Date.now()+33e5,auth:d},d}(t)}function i(t,e){const r=Number(t);return Number.isFinite(r)?r:e}function c(t,e){const r=t[e];if(!r)throw new Error(`Missing ${e}`);return r.replace(/\/+$/,"")}async function u(t,r){const n=await o(t),a=await fetch(e(t,r),{headers:{Accept:"application/json",...n.headers}}),s=await a.text();if(!a.ok)throw new Error(`Umami request failed: ${a.status} ${s}`);return s?JSON.parse(s):null}const d=3e5,f=new Map;let M=null;const m=[],p=[{name:"umami-analytics",config:(t,e)=>{const r=e.env||process.env;t.get("/health",(t,e)=>{e.json({ok:!0,service:"umami-analytics"})}),t.get("/debug",async(t,e)=>{try{const t=r.UMAMI_API_KEY?"api-key":r.UMAMI_TOKEN?"bearer-token":r.UMAMI_USER_ID||r.UMAMI_API_CLIENT_USER_ID?"api-client":"username-password",a=await u(r,"/me").catch(t=>({error:t.message}));e.json({ok:!0,authMode:t,umamiUrl:n(r),websiteId:r.UMAMI_WEBSITE_ID,me:a})}catch(t){e.status(500).json({error:t.message||"Unknown error"})}}),t.get("/summary",async(t,e)=>{try{const n=String(t.query.websiteId||r.UMAMI_WEBSITE_ID||"");if(!n)return e.status(400).json({error:"Missing websiteId or UMAMI_WEBSITE_ID"});const{startAt:a,endAt:s,days:o}=function(t){const e=Date.now(),r=Math.max(1,Math.min(365,i(t.days,30))),n=i(t.endAt,e);return{startAt:i(t.startAt,n-24*r*60*60*1e3),endAt:n,days:r}}(t.query),c=`summary:${n}:${a}:${s}`,M=function(t){const e=f.get(t);return!e||e.expires<Date.now()?null:e.value}(c);if(M)return e.json(M);const m=r.UMAMI_TIMEZONE||"Europe/Brussels",p=new URLSearchParams({startAt:String(Math.round(a)),endAt:String(Math.round(s)),unit:"day",timezone:m}),h=(t,e=10)=>new URLSearchParams({startAt:String(Math.round(a)),endAt:String(Math.round(s)),type:t,limit:String(e),timezone:m}),[l,A,g,I]=await Promise.all([u(r,`/websites/${n}/stats?${p.toString()}`),u(r,`/websites/${n}/metrics?${h("url").toString()}`),u(r,`/websites/${n}/metrics?${h("referrer").toString()}`),u(r,`/websites/${n}/metrics?${h("event").toString()}`).catch(()=>[])]),S={websiteId:n,range:{startAt:a,endAt:s,days:o},stats:l,pages:A,referrers:g,events:I};!function(t,e,r=d){f.set(t,{expires:Date.now()+r,value:e})}(c,S),e.json(S)}catch(t){e.status(500).json({error:t.message||"Unknown error"})}})}}],h=[];export{p as endpoints,m as hooks,h 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.2",
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": {
@@ -1,4 +1,4 @@
1
- import crypto from "node:crypto";
1
+ import crypto from 'node:crypto';
2
2
 
3
3
  type Env = Record<string, string | undefined>;
4
4
 
@@ -6,13 +6,57 @@ type UmamiAuth = {
6
6
  headers: Record<string, string>;
7
7
  };
8
8
 
9
+ const FIVE_MINUTES = 5 * 60 * 1000;
10
+ const cache = new Map<string, { expires: number; value: unknown }>();
11
+ let authCache: { expires: number; auth: UmamiAuth } | null = null;
12
+
13
+ function required(env: Env, key: string): string {
14
+ const value = env[key];
15
+ if (!value) throw new Error(`Missing ${key}`);
16
+ return value.replace(/\/+$/, '');
17
+ }
18
+
19
+ function cleanBaseUrl(env: Env): string {
20
+ return required(env, 'UMAMI_URL').replace(/\/api\/?$/, '');
21
+ }
22
+
9
23
  function apiUrl(env: Env, path: string): string {
10
24
  const cleanPath = path.replace(/^\/api\/?/, '').replace(/^\/+/, '');
11
25
  return `${cleanBaseUrl(env)}/api/${cleanPath}`;
12
26
  }
13
27
 
14
- function base64url(input: Buffer | string) {
15
- return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
28
+ function numberParam(value: unknown, fallback: number) {
29
+ const parsed = Number(value);
30
+ return Number.isFinite(parsed) ? parsed : fallback;
31
+ }
32
+
33
+ function getRange(query: Record<string, unknown>) {
34
+ const now = Date.now();
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)));
56
+ const endAt = numberParam(query.endAt, now);
57
+ const startAt = numberParam(query.startAt, endAt - days * 24 * 60 * 60 * 1000);
58
+
59
+ return { startAt, endAt, days, preset: preset || `${days}d` };
16
60
  }
17
61
 
18
62
  function cached<T>(key: string): T | null {
@@ -21,9 +65,16 @@ function cached<T>(key: string): T | null {
21
65
  return hit.value as T;
22
66
  }
23
67
 
24
- function cleanBaseUrl(env: Env): string {
25
- // Accept both https://analytics.example.com and https://analytics.example.com/api
26
- return required(env, 'UMAMI_URL').replace(/\/api\/?$/, '');
68
+ function setCached(key: string, value: unknown, ttlMs = FIVE_MINUTES) {
69
+ cache.set(key, { expires: Date.now() + ttlMs, value });
70
+ }
71
+
72
+ function base64url(input: Buffer | string) {
73
+ return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
74
+ }
75
+
76
+ function hash(...args: string[]) {
77
+ return crypto.createHash('sha512').update(args.join('')).digest('hex');
27
78
  }
28
79
 
29
80
  function createJwt(payload: Record<string, unknown>, secret: string) {
@@ -35,10 +86,6 @@ function createJwt(payload: Record<string, unknown>, secret: string) {
35
86
  return `${data}.${base64url(signature)}`;
36
87
  }
37
88
 
38
- function createSecureToken(payload: Record<string, unknown>, secret: string) {
39
- return encryptAesGcm(createJwt(payload, secret), secret);
40
- }
41
-
42
89
  function encryptAesGcm(value: string, secret: string) {
43
90
  const iv = crypto.randomBytes(16);
44
91
  const salt = crypto.randomBytes(64);
@@ -49,40 +96,22 @@ function encryptAesGcm(value: string, secret: string) {
49
96
  return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
50
97
  }
51
98
 
99
+ function createSecureToken(payload: Record<string, unknown>, secret: string) {
100
+ return encryptAesGcm(createJwt(payload, secret), secret);
101
+ }
102
+
52
103
  function getApiClientAuth(env: Env): UmamiAuth | null {
53
104
  const userId = env.UMAMI_USER_ID || env.UMAMI_API_CLIENT_USER_ID;
54
105
  const appSecret = env.UMAMI_APP_SECRET || env.UMAMI_API_CLIENT_SECRET;
55
106
 
56
107
  if (!userId || !appSecret) return null;
57
108
 
58
- // This matches @umami/api-client: secret is sha512(APP_SECRET), then createSecureToken({ userId }, secret)
59
109
  const secret = hash(appSecret);
60
110
  const token = createSecureToken({ userId }, secret);
61
111
 
62
112
  return { headers: { Authorization: `Bearer ${token}` } };
63
113
  }
64
114
 
65
- async function getAuth(env: Env): Promise<UmamiAuth> {
66
- const apiKey = env.UMAMI_API_KEY;
67
- if (apiKey) return { headers: { 'x-umami-api-key': apiKey } };
68
-
69
- const token = env.UMAMI_TOKEN;
70
- if (token) return { headers: { Authorization: `Bearer ${token}` } };
71
-
72
- const apiClientAuth = getApiClientAuth(env);
73
- if (apiClientAuth) return apiClientAuth;
74
-
75
- return getUsernamePasswordAuth(env);
76
- }
77
-
78
- function getRange(query: Record<string, unknown>) {
79
- const now = Date.now();
80
- const days = Math.max(1, Math.min(365, numberParam(query.days, 30)));
81
- const endAt = numberParam(query.endAt, now);
82
- const startAt = numberParam(query.startAt, endAt - days * 24 * 60 * 60 * 1000);
83
- return { startAt, endAt, days };
84
- }
85
-
86
115
  async function getUsernamePasswordAuth(env: Env): Promise<UmamiAuth> {
87
116
  if (authCache && authCache.expires > Date.now()) return authCache.auth;
88
117
 
@@ -119,33 +148,17 @@ async function getUsernamePasswordAuth(env: Env): Promise<UmamiAuth> {
119
148
  return auth;
120
149
  }
121
150
 
122
- function hash(...args: string[]) {
123
- return crypto.createHash('sha512').update(args.join('')).digest('hex');
124
- }
125
-
126
- function metricPath(websiteId: string, type: string, startAt: number, endAt: number, limit = 10) {
127
- const params = new URLSearchParams({
128
- startAt: String(Math.round(startAt)),
129
- endAt: String(Math.round(endAt)),
130
- type,
131
- limit: String(limit),
132
- });
133
- return `/websites/${websiteId}/metrics?${params.toString()}`;
134
- }
151
+ async function getAuth(env: Env): Promise<UmamiAuth> {
152
+ const apiKey = env.UMAMI_API_KEY;
153
+ if (apiKey) return { headers: { 'x-umami-api-key': apiKey } };
135
154
 
136
- function numberParam(value: unknown, fallback: number) {
137
- const parsed = Number(value);
138
- return Number.isFinite(parsed) ? parsed : fallback;
139
- }
155
+ const token = env.UMAMI_TOKEN;
156
+ if (token) return { headers: { Authorization: `Bearer ${token}` } };
140
157
 
141
- function required(env: Env, key: string): string {
142
- const value = env[key];
143
- if (!value) throw new Error(`Missing ${key}`);
144
- return value.replace(/\/+$/, '');
145
- }
158
+ const apiClientAuth = getApiClientAuth(env);
159
+ if (apiClientAuth) return apiClientAuth;
146
160
 
147
- function setCached(key: string, value: unknown, ttlMs = FIVE_MINUTES) {
148
- cache.set(key, { expires: Date.now() + ttlMs, value });
161
+ return getUsernamePasswordAuth(env);
149
162
  }
150
163
 
151
164
  async function umamiGet(env: Env, path: string) {
@@ -166,121 +179,95 @@ async function umamiGet(env: Env, path: string) {
166
179
  return text ? JSON.parse(text) : null;
167
180
  }
168
181
 
169
- const FIVE_MINUTES = 5 * 60 * 1000;
170
- const cache = new Map<string, { expires: number; value: unknown }>();
171
- let authCache: { expires: number; auth: UmamiAuth } | null = null;
172
-
173
- export default (router: any, context: any) => {
174
- const env: Env = context.env || process.env;
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
+ }
175
189
 
176
- router.get('/health', (_req: any, res: any) => {
177
- res.json({ ok: true, service: 'umami-analytics' });
190
+ function metricPath(websiteId: string, type: string, startAt: number, endAt: number, limit = 10) {
191
+ const params = new URLSearchParams({
192
+ startAt: String(Math.round(startAt)),
193
+ endAt: String(Math.round(endAt)),
194
+ type,
195
+ limit: String(limit),
178
196
  });
197
+ return `/websites/${websiteId}/metrics?${params.toString()}`;
198
+ }
179
199
 
180
- router.get('/debug', async (_req: any, res: any) => {
181
- try {
182
- const authMode = env.UMAMI_API_KEY
183
- ? 'api-key'
184
- : env.UMAMI_TOKEN
185
- ? 'bearer-token'
186
- : env.UMAMI_USER_ID || env.UMAMI_API_CLIENT_USER_ID
187
- ? 'api-client'
188
- : 'username-password';
189
-
190
- const me = await umamiGet(env, '/me').catch((error: any) => ({ error: error.message }));
191
-
192
- res.json({
193
- ok: true,
194
- authMode,
195
- umamiUrl: cleanBaseUrl(env),
196
- websiteId: env.UMAMI_WEBSITE_ID,
197
- me,
198
- });
199
- } catch (error: any) {
200
- res.status(500).json({ error: error.message || 'Unknown error' });
201
- }
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,
202
205
  });
206
+ return `/websites/${websiteId}/pageviews?${params.toString()}`;
207
+ }
203
208
 
204
- router.get('/summary', async (req: any, res: any) => {
205
- try {
206
- const websiteId = String(req.query.websiteId || env.UMAMI_WEBSITE_ID || '');
207
-
208
- if (!websiteId) {
209
- return res.status(400).json({
210
- error: 'Missing websiteId or UMAMI_WEBSITE_ID',
211
- });
212
- }
213
-
214
- const { startAt, endAt, days } = getRange(req.query);
209
+ function unitForDays(days: number) {
210
+ if (days <= 2) return 'hour';
211
+ if (days <= 90) return 'day';
212
+ return 'month';
213
+ }
215
214
 
216
- const key = `summary:${websiteId}:${startAt}:${endAt}`;
215
+ export default (router: any, context: any) => {
216
+ const env: Env = context.env || process.env;
217
217
 
218
- const hit = cached(key);
218
+ router.get('/health', (_req: any, res: any) => {
219
+ res.json({ ok: true, service: 'umami-analytics', version: '2.0.0' });
220
+ });
219
221
 
220
- if (hit) {
221
- return res.json(hit);
222
- }
222
+
223
223
 
224
- const timezone = env.UMAMI_TIMEZONE || 'Europe/Brussels';
224
+ router.get('/summary', async (req: any, res: any) => {
225
+ try {
226
+ const websiteId = String(req.query.websiteId || env.UMAMI_WEBSITE_ID || '');
227
+ if (!websiteId) return res.status(400).json({ error: 'Missing websiteId or UMAMI_WEBSITE_ID' });
225
228
 
226
- const statsParams = new URLSearchParams({
227
- startAt: String(Math.round(startAt)),
228
- endAt: String(Math.round(endAt)),
229
- unit: 'day',
230
- timezone,
231
- });
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}`;
232
+ const hit = cached(key);
233
+ if (hit) return res.json(hit);
232
234
 
233
- const metricsParams = (type: string, limit = 10) =>
234
- new URLSearchParams({
235
+ const statsParams = new URLSearchParams({
235
236
  startAt: String(Math.round(startAt)),
236
237
  endAt: String(Math.round(endAt)),
237
- type,
238
- limit: String(limit),
239
- timezone,
240
238
  });
241
239
 
242
- const [stats, pages, referrers, events] = await Promise.all([
243
- umamiGet(
244
- env,
245
- `/websites/${websiteId}/stats?${statsParams.toString()}`
246
- ),
247
-
248
- umamiGet(
249
- env,
250
- `/websites/${websiteId}/metrics?${metricsParams('url').toString()}`
251
- ),
252
-
253
- umamiGet(
254
- env,
255
- `/websites/${websiteId}/metrics?${metricsParams('referrer').toString()}`
256
- ),
257
-
258
- umamiGet(
259
- env,
260
- `/websites/${websiteId}/metrics?${metricsParams('event').toString()}`
261
- ).catch(() => []),
262
- ]);
263
-
264
- const payload = {
265
- websiteId,
266
- range: {
267
- startAt,
268
- endAt,
269
- days,
270
- },
271
- stats,
272
- pages,
273
- referrers,
274
- events,
275
- };
276
-
277
- setCached(key, payload);
278
-
279
- res.json(payload);
280
- } catch (error: any) {
281
- res.status(500).json({
282
- error: error.message || 'Unknown error',
283
- });
284
- }
285
- });
240
+ const stats = await umamiGet(env, `/websites/${websiteId}/stats?${statsParams.toString()}`);
241
+
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), []),
251
+ ]);
252
+
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
+
267
+ setCached(key, payload);
268
+ res.json(payload);
269
+ } catch (error: any) {
270
+ res.status(500).json({ error: error.message || 'Unknown error' });
271
+ }
272
+ });
286
273
  };
@@ -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>