@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 +1 -1
- package/dist/app.js +1 -1
- package/package.json +2 -2
- package/src/umami-analytics-endpoint/index.ts +72 -48
- package/src/umami-analytics-panel/panel.vue +601 -92
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
|
|
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": "
|
|
4
|
-
"description": "Directus
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
238
|
-
safeUmamiGet(env, metricPath(websiteId, '
|
|
239
|
-
safeUmamiGet(env, metricPath(websiteId, '
|
|
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 = {
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
<
|
|
26
|
-
<section>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
|
|
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
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
<
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
</
|
|
56
|
-
</
|
|
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
|
|
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
|
-
|
|
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 *
|
|
147
|
+
return `${Math.round(value * 10) / 10}%`;
|
|
90
148
|
}
|
|
91
149
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
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],
|
|
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
|
-
|
|
335
|
+
min-width: 0;
|
|
336
|
+
overflow: hidden;
|
|
337
|
+
background: var(--theme--background);
|
|
338
|
+
color: var(--theme--foreground);
|
|
128
339
|
}
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
370
|
+
|
|
371
|
+
.heading h2 {
|
|
372
|
+
margin: 0;
|
|
373
|
+
font-size: 22px;
|
|
374
|
+
font-weight: 750;
|
|
375
|
+
line-height: 1.15;
|
|
135
376
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
385
|
+
|
|
386
|
+
.controls button {
|
|
143
387
|
border: var(--theme--border-width) solid var(--theme--border-color);
|
|
144
|
-
border-radius:
|
|
145
|
-
padding:
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
584
|
+
|
|
585
|
+
.metric-card li {
|
|
173
586
|
display: flex;
|
|
174
|
-
|
|
175
|
-
gap:
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
.
|
|
190
|
-
|
|
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>
|