@tdhftw/emdash-plugin-umami 0.0.0-stage → 0.1.2
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/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +132 -2
- package/dist/index.d.ts +144 -0
- package/dist/index.js +796 -0
- package/package.json +74 -4
- package/src/admin/AnalyticsPage.tsx +73 -0
- package/src/admin/Chart.tsx +23 -0
- package/src/admin/OverviewView.tsx +150 -0
- package/src/admin/SettingsView.tsx +319 -0
- package/src/admin/Widget.tsx +75 -0
- package/src/admin/api.ts +14 -0
- package/src/admin/chartOptions.ts +54 -0
- package/src/admin/format.ts +119 -0
- package/src/admin/hooks.ts +74 -0
- package/src/admin/index.tsx +13 -0
- package/src/admin/query.tsx +29 -0
- package/src/shared/api-types.ts +43 -0
- package/src/shared/constants.ts +35 -0
- package/src/shared/settings-types.ts +17 -0
- package/src/shared/stats.ts +52 -0
- package/src/shared/tracker.ts +10 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Loader } from "@cloudflare/kumo";
|
|
2
|
+
|
|
3
|
+
import type { Overview } from "../shared/stats.js";
|
|
4
|
+
import { change, formatNumber } from "./format.js";
|
|
5
|
+
import { useActive, useOverview } from "./hooks.js";
|
|
6
|
+
|
|
7
|
+
/** Tiny dependency-free sparkline, so the dashboard never loads the charting library. */
|
|
8
|
+
function Sparkline({ values }: { values: number[] }) {
|
|
9
|
+
const w = 240;
|
|
10
|
+
const h = 40;
|
|
11
|
+
const max = Math.max(1, ...values);
|
|
12
|
+
const step = values.length > 1 ? w / (values.length - 1) : w;
|
|
13
|
+
const points = values.map((v, i) => `${(i * step).toFixed(1)},${(h - (v / max) * (h - 2) - 1).toFixed(1)}`).join(" ");
|
|
14
|
+
return (
|
|
15
|
+
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden>
|
|
16
|
+
<polyline points={points} fill="none" stroke="var(--color-kumo-brand)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
|
17
|
+
</svg>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Stat({ label, value, previous }: { label: string; value: number; previous: number }) {
|
|
22
|
+
const c = change(value, previous);
|
|
23
|
+
const pct = c === null ? null : Math.round(c * 100);
|
|
24
|
+
return (
|
|
25
|
+
<div>
|
|
26
|
+
<div className="text-xs text-kumo-subtle">{label}</div>
|
|
27
|
+
<div className="text-xl font-semibold tabular-nums">{formatNumber(value)}</div>
|
|
28
|
+
{pct !== null && pct !== 0 ? (
|
|
29
|
+
<div className={`text-xs tabular-nums ${pct > 0 ? "text-kumo-success" : "text-kumo-danger"}`}>
|
|
30
|
+
{pct > 0 ? "▲" : "▼"} {Math.abs(pct)}%
|
|
31
|
+
</div>
|
|
32
|
+
) : null}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Summary({ overview }: { overview: Overview }) {
|
|
38
|
+
const active = useActive();
|
|
39
|
+
return (
|
|
40
|
+
<div className="space-y-3">
|
|
41
|
+
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}>
|
|
42
|
+
<Stat label="Visitors" value={overview.totals.visitors} previous={overview.previous.visitors} />
|
|
43
|
+
<Stat label="Pageviews" value={overview.totals.pageviews} previous={overview.previous.pageviews} />
|
|
44
|
+
<div>
|
|
45
|
+
<div className="text-xs text-kumo-subtle">Active now</div>
|
|
46
|
+
<div className="text-xl font-semibold tabular-nums">{active.data ? active.data.visitors : "–"}</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<Sparkline values={overview.series.map((p) => p.visitors)} />
|
|
50
|
+
<div className="flex items-center justify-between text-xs text-kumo-subtle">
|
|
51
|
+
<span>Visitors, last 7 days</span>
|
|
52
|
+
<a href={`/_emdash/admin/plugins/umami/`} className="underline">
|
|
53
|
+
Open Analytics
|
|
54
|
+
</a>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function OverviewWidget() {
|
|
61
|
+
const overview = useOverview("7d");
|
|
62
|
+
if (overview.data) return <Summary overview={overview.data} />;
|
|
63
|
+
if (overview.error) {
|
|
64
|
+
return (
|
|
65
|
+
<p className="text-sm text-kumo-subtle">
|
|
66
|
+
{(overview.error as Error).message}{" "}
|
|
67
|
+
<a href="/_emdash/admin/plugins/umami/#settings" className="underline">
|
|
68
|
+
Settings
|
|
69
|
+
</a>
|
|
70
|
+
</p>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return <Loader />;
|
|
74
|
+
}
|
|
75
|
+
|
package/src/admin/api.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
2
|
+
|
|
3
|
+
import { API_BASE } from "../shared/constants.js";
|
|
4
|
+
|
|
5
|
+
/** GET or POST a plugin route and unwrap EmDash's `{ success, data }` envelope. */
|
|
6
|
+
export async function callRoute<T>(route: string, body?: unknown): Promise<T> {
|
|
7
|
+
const response = await apiFetch(
|
|
8
|
+
`${API_BASE}/${route}`,
|
|
9
|
+
body === undefined
|
|
10
|
+
? undefined
|
|
11
|
+
: { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) },
|
|
12
|
+
);
|
|
13
|
+
return parseApiResponse<T>(response, "The Umami plugin did not answer");
|
|
14
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Kept free of ECharts and Kumo imports so it can be unit-tested and stays out of shared chunks.
|
|
2
|
+
import type { Overview } from "../shared/stats.js";
|
|
3
|
+
|
|
4
|
+
const dayLabel = new Intl.DateTimeFormat(undefined, { timeZone: "UTC", day: "numeric", month: "short" });
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Axis label for a series bucket. Bucket labels are already in the site's zone ("2026-10-21" or
|
|
8
|
+
* "2026-10-21 14:00"), so they are reformatted as text rather than from `t`, which a time axis
|
|
9
|
+
* would place in the browser's zone.
|
|
10
|
+
*/
|
|
11
|
+
export function bucketLabel(label: string, unit: Overview["unit"]): string {
|
|
12
|
+
if (unit === "hour") return label.slice(11, 16);
|
|
13
|
+
const [y, m, d] = label.split("-").map(Number);
|
|
14
|
+
return dayLabel.format(Date.UTC(y!, m! - 1, d!));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ECharts options for the Overview chart. A category axis with one bar slot per bucket, and the
|
|
19
|
+
* two series drawn over each other (`barGap: "-100%"`) rather than stacked: visitors never exceed
|
|
20
|
+
* pageviews, so each bar's height is its real count, as in Umami's own chart.
|
|
21
|
+
*/
|
|
22
|
+
export function chartOptions(overview: Overview, colors: { pageviews: string; visitors: string }) {
|
|
23
|
+
const series = (name: string, color: string, values: number[]) => ({
|
|
24
|
+
type: "bar" as const,
|
|
25
|
+
name,
|
|
26
|
+
color,
|
|
27
|
+
data: values,
|
|
28
|
+
barGap: "-100%",
|
|
29
|
+
barMaxWidth: 24,
|
|
30
|
+
emphasis: { focus: "series" as const },
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
aria: { enabled: true, label: { description: `Pageviews and visitors per ${overview.unit}` } },
|
|
34
|
+
backgroundColor: "transparent",
|
|
35
|
+
legend: { top: 0, right: 0, icon: "roundRect", itemWidth: 10, itemHeight: 10 },
|
|
36
|
+
tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const } },
|
|
37
|
+
grid: { left: 8, right: 8, top: 32, bottom: 0, containLabel: true },
|
|
38
|
+
xAxis: {
|
|
39
|
+
type: "category" as const,
|
|
40
|
+
data: overview.series.map((p) => bucketLabel(p.label, overview.unit)),
|
|
41
|
+
axisTick: { alignWithLabel: true },
|
|
42
|
+
axisLabel: { hideOverlap: true },
|
|
43
|
+
},
|
|
44
|
+
yAxis: {
|
|
45
|
+
type: "value" as const,
|
|
46
|
+
minInterval: 1,
|
|
47
|
+
splitLine: { lineStyle: { type: "dashed" as const } },
|
|
48
|
+
},
|
|
49
|
+
series: [
|
|
50
|
+
series("Pageviews", colors.pageviews, overview.series.map((p) => p.pageviews)),
|
|
51
|
+
series("Visitors", colors.visitors, overview.series.map((p) => p.visitors)),
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { MetricType, Range, Totals } from "../shared/stats.js";
|
|
2
|
+
|
|
3
|
+
const whole = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
|
|
4
|
+
const compact = new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 });
|
|
5
|
+
|
|
6
|
+
export function formatNumber(n: number): string {
|
|
7
|
+
return n >= 10_000 ? compact.format(n) : whole.format(n);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatDuration(seconds: number): string {
|
|
11
|
+
const s = Math.round(seconds);
|
|
12
|
+
if (s < 60) return `${s}s`;
|
|
13
|
+
const m = Math.floor(s / 60);
|
|
14
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
|
|
15
|
+
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatPercent(ratio: number): string {
|
|
19
|
+
return `${Math.round(ratio * 100)}%`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Kpi {
|
|
23
|
+
key: string;
|
|
24
|
+
label: string;
|
|
25
|
+
value: number;
|
|
26
|
+
previous: number;
|
|
27
|
+
format: (n: number) => string;
|
|
28
|
+
/** Bounce rate going down is good. */
|
|
29
|
+
lowerIsBetter?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function kpis(t: Totals, p: Totals): Kpi[] {
|
|
33
|
+
const bounceRate = (x: Totals) => (x.visits > 0 ? Math.min(x.bounces, x.visits) / x.visits : 0);
|
|
34
|
+
const avgVisit = (x: Totals) => (x.visits > 0 ? x.totaltime / x.visits : 0);
|
|
35
|
+
return [
|
|
36
|
+
{ key: "visitors", label: "Visitors", value: t.visitors, previous: p.visitors, format: formatNumber },
|
|
37
|
+
{ key: "visits", label: "Visits", value: t.visits, previous: p.visits, format: formatNumber },
|
|
38
|
+
{ key: "pageviews", label: "Pageviews", value: t.pageviews, previous: p.pageviews, format: formatNumber },
|
|
39
|
+
{ key: "bounce", label: "Bounce rate", value: bounceRate(t), previous: bounceRate(p), format: formatPercent, lowerIsBetter: true },
|
|
40
|
+
{ key: "duration", label: "Visit duration", value: avgVisit(t), previous: avgVisit(p), format: formatDuration },
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Relative change, or null when there is nothing to compare against. */
|
|
45
|
+
export function change(value: number, previous: number): number | null {
|
|
46
|
+
if (previous <= 0) return null;
|
|
47
|
+
return (value - previous) / previous;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const RANGE_LABELS: Record<Range, string> = {
|
|
51
|
+
"24h": "24 hours",
|
|
52
|
+
"7d": "7 days",
|
|
53
|
+
"30d": "30 days",
|
|
54
|
+
"90d": "90 days",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const METRIC_TITLES: Record<MetricType, string> = {
|
|
58
|
+
path: "Pages",
|
|
59
|
+
referrer: "Referrers",
|
|
60
|
+
country: "Countries",
|
|
61
|
+
browser: "Browsers",
|
|
62
|
+
os: "Operating systems",
|
|
63
|
+
device: "Devices",
|
|
64
|
+
event: "Events",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const regionNames = (() => {
|
|
68
|
+
try {
|
|
69
|
+
return new Intl.DisplayNames(undefined, { type: "region" });
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
|
|
75
|
+
const BROWSERS: Record<string, string> = {
|
|
76
|
+
chrome: "Chrome",
|
|
77
|
+
crios: "Chrome (iOS)",
|
|
78
|
+
firefox: "Firefox",
|
|
79
|
+
fxios: "Firefox (iOS)",
|
|
80
|
+
safari: "Safari",
|
|
81
|
+
ios: "Safari (iOS)",
|
|
82
|
+
"ios-webview": "iOS web view",
|
|
83
|
+
edge: "Edge",
|
|
84
|
+
"edge-chromium": "Edge",
|
|
85
|
+
"edge-ios": "Edge (iOS)",
|
|
86
|
+
opera: "Opera",
|
|
87
|
+
samsung: "Samsung Internet",
|
|
88
|
+
"chromium-webview": "Android web view",
|
|
89
|
+
facebook: "Facebook",
|
|
90
|
+
instagram: "Instagram",
|
|
91
|
+
brave: "Brave",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function capitalize(s: string) {
|
|
95
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function metricLabel(type: MetricType, label: string): string {
|
|
99
|
+
if (label === "(none)") {
|
|
100
|
+
return type === "referrer" ? "Direct or unknown" : "Unknown";
|
|
101
|
+
}
|
|
102
|
+
if (type === "country") {
|
|
103
|
+
try {
|
|
104
|
+
return regionNames?.of(label.toUpperCase()) ?? label;
|
|
105
|
+
} catch {
|
|
106
|
+
return label;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (type === "browser") return BROWSERS[label] ?? capitalize(label);
|
|
110
|
+
if (type === "device") return capitalize(label);
|
|
111
|
+
return label;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function relativeTime(t: number, now = Date.now()): string {
|
|
115
|
+
const seconds = Math.max(0, Math.round((now - t) / 1000));
|
|
116
|
+
if (seconds < 60) return "just now";
|
|
117
|
+
const minutes = Math.round(seconds / 60);
|
|
118
|
+
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
|
|
119
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import type { StatsResult } from "../shared/api-types.js";
|
|
5
|
+
import { RANGES, type Active, type Overview, type Range } from "../shared/stats.js";
|
|
6
|
+
import { callRoute } from "./api.js";
|
|
7
|
+
|
|
8
|
+
export function viewerTimeZone(): string {
|
|
9
|
+
try {
|
|
10
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
11
|
+
} catch {
|
|
12
|
+
return "UTC";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Unwraps `{ ok: false }` results into errors so React Query sees them as failures. */
|
|
17
|
+
async function unwrap<T>(route: string): Promise<T> {
|
|
18
|
+
const result = await callRoute<StatsResult<T>>(route);
|
|
19
|
+
if (!result.ok) throw Object.assign(new Error(result.message), { code: result.code });
|
|
20
|
+
return result.data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useOverview(range: Range) {
|
|
24
|
+
const tz = viewerTimeZone();
|
|
25
|
+
return useQuery({
|
|
26
|
+
queryKey: ["plugin-umami", "overview", range, tz],
|
|
27
|
+
queryFn: () => unwrap<Overview>(`overview?range=${range}&tz=${encodeURIComponent(tz)}`),
|
|
28
|
+
placeholderData: (previous) => previous,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useActive() {
|
|
33
|
+
return useQuery({
|
|
34
|
+
queryKey: ["plugin-umami", "active"],
|
|
35
|
+
queryFn: () => unwrap<Active>("active"),
|
|
36
|
+
refetchInterval: 30_000,
|
|
37
|
+
refetchIntervalInBackground: false,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const RANGE_KEY = "plugin-umami:range";
|
|
42
|
+
|
|
43
|
+
/** Selected range, remembered per browser. */
|
|
44
|
+
export function useRange(): [Range, (range: Range) => void] {
|
|
45
|
+
const [range, setRange] = React.useState<Range>(() => {
|
|
46
|
+
try {
|
|
47
|
+
const saved = window.localStorage.getItem(RANGE_KEY);
|
|
48
|
+
return (RANGES as readonly string[]).includes(saved ?? "") ? (saved as Range) : "7d";
|
|
49
|
+
} catch {
|
|
50
|
+
return "7d";
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const choose = React.useCallback((next: Range) => {
|
|
54
|
+
setRange(next);
|
|
55
|
+
try {
|
|
56
|
+
window.localStorage.setItem(RANGE_KEY, next);
|
|
57
|
+
} catch {
|
|
58
|
+
// Storage can be unavailable; the choice then lasts for this page view.
|
|
59
|
+
}
|
|
60
|
+
}, []);
|
|
61
|
+
return [range, choose];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Follows the admin's light/dark switch, which lives on <html data-mode>. */
|
|
65
|
+
export function useDarkMode(): boolean {
|
|
66
|
+
const read = () => typeof document !== "undefined" && document.documentElement.getAttribute("data-mode") === "dark";
|
|
67
|
+
const [dark, setDark] = React.useState(read);
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
const observer = new MutationObserver(() => setDark(read()));
|
|
70
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-mode", "class"] });
|
|
71
|
+
return () => observer.disconnect();
|
|
72
|
+
}, []);
|
|
73
|
+
return dark;
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PluginAdminExports } from "emdash";
|
|
2
|
+
|
|
3
|
+
import { AnalyticsPage } from "./AnalyticsPage.js";
|
|
4
|
+
import { withQueryClient } from "./query.js";
|
|
5
|
+
import { OverviewWidget } from "./Widget.js";
|
|
6
|
+
|
|
7
|
+
export const pages: PluginAdminExports["pages"] = {
|
|
8
|
+
"/": withQueryClient(AnalyticsPage),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const widgets: PluginAdminExports["widgets"] = {
|
|
12
|
+
"umami-overview": withQueryClient(OverviewWidget),
|
|
13
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
2
|
+
import type { ComponentType } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The plugin brings its own QueryClient instead of using the admin's.
|
|
6
|
+
*
|
|
7
|
+
* In `astro dev`, EmDash pre-bundles @emdash-cms/admin with private copies of @tanstack/react-query
|
|
8
|
+
* and @lingui/react, so a plugin that imports those packages gets different module instances and
|
|
9
|
+
* cannot see the admin's providers ("useLingui hook was used without I18nProvider"). A production
|
|
10
|
+
* build shares one copy, but relying on that would break local development. So: no host-context
|
|
11
|
+
* hooks in plugin components; wrap each exported page and widget with this provider instead.
|
|
12
|
+
*/
|
|
13
|
+
const queryClient = new QueryClient({
|
|
14
|
+
defaultOptions: {
|
|
15
|
+
queries: { staleTime: 60_000, retry: 1, refetchOnWindowFocus: false },
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function withQueryClient<P extends object>(Component: ComponentType<P>): ComponentType<P> {
|
|
20
|
+
function WithQueryClient(props: P) {
|
|
21
|
+
return (
|
|
22
|
+
<QueryClientProvider client={queryClient}>
|
|
23
|
+
<Component {...props} />
|
|
24
|
+
</QueryClientProvider>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
WithQueryClient.displayName = `WithQueryClient(${Component.displayName ?? Component.name})`;
|
|
28
|
+
return WithQueryClient;
|
|
29
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Route payloads shared by the server routes and the admin UI (which ships as source). */
|
|
2
|
+
import type { UmamiSettingsValues } from "./settings-types.js";
|
|
3
|
+
|
|
4
|
+
export type UmamiErrorCode =
|
|
5
|
+
| "not-configured"
|
|
6
|
+
| "unauthorized"
|
|
7
|
+
| "forbidden"
|
|
8
|
+
| "not-found"
|
|
9
|
+
| "rate-limited"
|
|
10
|
+
| "unreachable"
|
|
11
|
+
| "upstream";
|
|
12
|
+
|
|
13
|
+
export interface UmamiWebsite {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
domain: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Settings as the admin sees them: everything except secret values. */
|
|
20
|
+
export interface SettingsView {
|
|
21
|
+
values: UmamiSettingsValues;
|
|
22
|
+
secretsSet: { apiKey: boolean; password: boolean };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ConnectionTestResult =
|
|
26
|
+
| { ok: true; websites: UmamiWebsite[]; website: UmamiWebsite | null }
|
|
27
|
+
| { ok: false; code: UmamiErrorCode; message: string };
|
|
28
|
+
|
|
29
|
+
export type StatsResult<T> = { ok: true; data: T } | { ok: false; code: UmamiErrorCode; message: string };
|
|
30
|
+
|
|
31
|
+
/** Body of `settings/save`. Secrets: a string replaces, null clears, omitted keeps. */
|
|
32
|
+
export interface SaveSettingsInput {
|
|
33
|
+
hosting?: "self-hosted" | "cloud";
|
|
34
|
+
baseUrl?: string | null;
|
|
35
|
+
region?: "default" | "us" | "eu";
|
|
36
|
+
authMode?: "apiKey" | "password";
|
|
37
|
+
apiKey?: string | null;
|
|
38
|
+
username?: string | null;
|
|
39
|
+
password?: string | null;
|
|
40
|
+
websiteId?: string | null;
|
|
41
|
+
injectScript?: boolean;
|
|
42
|
+
scriptUrl?: string | null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Plugin id. One URL segment: routes mount at /_emdash/api/plugins/umami/<route>. */
|
|
2
|
+
export const PLUGIN_ID = "umami";
|
|
3
|
+
|
|
4
|
+
/** Keep in sync with package.json; test/plugin.test.ts enforces it. */
|
|
5
|
+
export const PLUGIN_VERSION = "0.1.2";
|
|
6
|
+
|
|
7
|
+
export const PACKAGE_NAME = "@tdhftw/emdash-plugin-umami";
|
|
8
|
+
|
|
9
|
+
export const API_BASE = `/_emdash/api/plugins/${PLUGIN_ID}`;
|
|
10
|
+
|
|
11
|
+
export type Hosting = "self-hosted" | "cloud";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Umami Cloud support is implemented but not yet verified against a Cloud account, so it is
|
|
15
|
+
* switched off for 0.1.x: the option shows as disabled and the server refuses to save it.
|
|
16
|
+
* Planned for 0.2.0.
|
|
17
|
+
*/
|
|
18
|
+
export const CLOUD_ENABLED = false;
|
|
19
|
+
export type AuthMode = "apiKey" | "password";
|
|
20
|
+
export type CloudRegion = "default" | "us" | "eu";
|
|
21
|
+
|
|
22
|
+
/** Shape returned by the `status` route. Shared by server and admin. */
|
|
23
|
+
export interface StatusResponse {
|
|
24
|
+
configured: boolean;
|
|
25
|
+
/** Settings that still need a value before the plugin can query Umami. */
|
|
26
|
+
missing: string[];
|
|
27
|
+
hosting: Hosting;
|
|
28
|
+
authMode: AuthMode;
|
|
29
|
+
websiteId: string | null;
|
|
30
|
+
/** Origin of the Umami dashboard, for "open in Umami" links. Never contains credentials. */
|
|
31
|
+
dashboardUrl: string | null;
|
|
32
|
+
injectScript: boolean;
|
|
33
|
+
/** Whether the host handed this plugin a ctx.http client. */
|
|
34
|
+
hasHttp: boolean;
|
|
35
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AuthMode, CloudRegion, Hosting } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
export interface UmamiSettingsValues {
|
|
4
|
+
hosting: Hosting;
|
|
5
|
+
baseUrl: string | null;
|
|
6
|
+
region: CloudRegion;
|
|
7
|
+
authMode: AuthMode;
|
|
8
|
+
username: string | null;
|
|
9
|
+
websiteId: string | null;
|
|
10
|
+
injectScript: boolean;
|
|
11
|
+
scriptUrl: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UmamiSettings extends UmamiSettingsValues {
|
|
15
|
+
apiKey: string | null;
|
|
16
|
+
password: string | null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Stats shapes shared by the server routes and the admin UI. */
|
|
2
|
+
|
|
3
|
+
export const RANGES = ["24h", "7d", "30d", "90d"] as const;
|
|
4
|
+
export type Range = (typeof RANGES)[number];
|
|
5
|
+
|
|
6
|
+
export const METRIC_TYPES = ["path", "referrer", "country", "browser", "os", "device", "event"] as const;
|
|
7
|
+
export type MetricType = (typeof METRIC_TYPES)[number];
|
|
8
|
+
|
|
9
|
+
export interface Totals {
|
|
10
|
+
pageviews: number;
|
|
11
|
+
visitors: number;
|
|
12
|
+
visits: number;
|
|
13
|
+
bounces: number;
|
|
14
|
+
/** Total visit duration in seconds. */
|
|
15
|
+
totaltime: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SeriesPoint {
|
|
19
|
+
/** Bucket start as a wall-clock label in the requested time zone: "2026-09-28" or "2026-09-28 14:00". */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Bucket start as epoch milliseconds (UTC instant), for charting. */
|
|
22
|
+
t: number;
|
|
23
|
+
pageviews: number;
|
|
24
|
+
visitors: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MetricRow {
|
|
28
|
+
label: string;
|
|
29
|
+
value: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Overview {
|
|
33
|
+
websiteId: string;
|
|
34
|
+
range: Range;
|
|
35
|
+
unit: "hour" | "day";
|
|
36
|
+
timezone: string;
|
|
37
|
+
startAt: number;
|
|
38
|
+
endAt: number;
|
|
39
|
+
totals: Totals;
|
|
40
|
+
/** Same-length period immediately before this one. */
|
|
41
|
+
previous: Totals;
|
|
42
|
+
series: SeriesPoint[];
|
|
43
|
+
top: Record<MetricType, MetricRow[]>;
|
|
44
|
+
fetchedAt: number;
|
|
45
|
+
/** True when Umami could not be reached and this is an older cached copy. */
|
|
46
|
+
stale: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Active {
|
|
50
|
+
visitors: number;
|
|
51
|
+
fetchedAt: number;
|
|
52
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Hosting } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
export const CLOUD_SCRIPT_URL = "https://cloud.umami.is/script.js";
|
|
4
|
+
|
|
5
|
+
/** The tracker script a site should load: the custom URL if set, else the hosting default. */
|
|
6
|
+
export function defaultScriptUrl(hosting: Hosting, baseUrl: string | null, scriptUrl: string | null): string | null {
|
|
7
|
+
if (scriptUrl) return scriptUrl;
|
|
8
|
+
if (hosting === "cloud") return CLOUD_SCRIPT_URL;
|
|
9
|
+
return baseUrl ? `${baseUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/script.js` : null;
|
|
10
|
+
}
|