@tdhftw/emdash-plugin-umami 0.0.0-stage → 0.1.3
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 +39 -0
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/dist/index.d.ts +146 -0
- package/dist/index.js +827 -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 +333 -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 +44 -0
- package/src/shared/constants.ts +35 -0
- package/src/shared/settings-types.ts +18 -0
- package/src/shared/stats.ts +52 -0
- package/src/shared/tracker.ts +10 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { definePlugin } from "emdash";
|
|
2
|
+
import { z } from "astro/zod";
|
|
3
|
+
//#region src/cache.ts
|
|
4
|
+
/**
|
|
5
|
+
* Returns a cached value younger than `maxAgeMs`, otherwise loads a fresh one. If loading fails and
|
|
6
|
+
* any cached copy exists, that copy is returned as stale instead of failing the request.
|
|
7
|
+
*/
|
|
8
|
+
async function cached(kv, key, maxAgeMs, load, now = Date.now()) {
|
|
9
|
+
const entry = await kv.get(key);
|
|
10
|
+
if (entry && now - entry.at < maxAgeMs) return {
|
|
11
|
+
value: entry.value,
|
|
12
|
+
stale: false
|
|
13
|
+
};
|
|
14
|
+
try {
|
|
15
|
+
const value = await load();
|
|
16
|
+
await kv.set(key, {
|
|
17
|
+
at: now,
|
|
18
|
+
value
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
value,
|
|
22
|
+
stale: false
|
|
23
|
+
};
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (entry) return {
|
|
26
|
+
value: entry.value,
|
|
27
|
+
stale: true
|
|
28
|
+
};
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/shared/constants.ts
|
|
34
|
+
/** Plugin id. One URL segment: routes mount at /_emdash/api/plugins/umami/<route>. */
|
|
35
|
+
const PLUGIN_ID = "umami";
|
|
36
|
+
/** Keep in sync with package.json; test/plugin.test.ts enforces it. */
|
|
37
|
+
const PLUGIN_VERSION = "0.1.3";
|
|
38
|
+
const PACKAGE_NAME = "@tdhftw/emdash-plugin-umami";
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/settings.ts
|
|
41
|
+
/**
|
|
42
|
+
* Settings form schema. EmDash renders it under Plugins → Umami → settings; `secret` fields are
|
|
43
|
+
* encrypted with EMDASH_ENCRYPTION_KEY and never sent back to the browser. Defaults only fill the
|
|
44
|
+
* form, they are not persisted, so readSettings() applies the same fallbacks.
|
|
45
|
+
*/
|
|
46
|
+
const settingsSchema = {
|
|
47
|
+
hosting: {
|
|
48
|
+
type: "select",
|
|
49
|
+
label: "Hosting",
|
|
50
|
+
description: "Tip: Analytics → Settings in the sidebar has a guided setup with a connection test and a website picker.",
|
|
51
|
+
options: [{
|
|
52
|
+
value: "self-hosted",
|
|
53
|
+
label: "Self-hosted Umami"
|
|
54
|
+
}, ...[]],
|
|
55
|
+
default: "self-hosted"
|
|
56
|
+
},
|
|
57
|
+
baseUrl: {
|
|
58
|
+
type: "url",
|
|
59
|
+
label: "Umami URL (self-hosted)",
|
|
60
|
+
placeholder: "https://stats.example.com"
|
|
61
|
+
},
|
|
62
|
+
region: {
|
|
63
|
+
type: "select",
|
|
64
|
+
label: "Region (Umami Cloud, coming in 0.2.0)",
|
|
65
|
+
options: [
|
|
66
|
+
{
|
|
67
|
+
value: "default",
|
|
68
|
+
label: "Account default"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
value: "us",
|
|
72
|
+
label: "United States"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
value: "eu",
|
|
76
|
+
label: "European Union"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
default: "default"
|
|
80
|
+
},
|
|
81
|
+
authMode: {
|
|
82
|
+
type: "select",
|
|
83
|
+
label: "Sign in with",
|
|
84
|
+
options: [{
|
|
85
|
+
value: "apiKey",
|
|
86
|
+
label: "API key"
|
|
87
|
+
}, {
|
|
88
|
+
value: "password",
|
|
89
|
+
label: "Username and password (older self-hosted Umami)"
|
|
90
|
+
}],
|
|
91
|
+
default: "apiKey"
|
|
92
|
+
},
|
|
93
|
+
apiKey: {
|
|
94
|
+
type: "secret",
|
|
95
|
+
label: "API key",
|
|
96
|
+
description: "Umami → Settings → API keys."
|
|
97
|
+
},
|
|
98
|
+
username: {
|
|
99
|
+
type: "string",
|
|
100
|
+
label: "Username (password sign-in)"
|
|
101
|
+
},
|
|
102
|
+
password: {
|
|
103
|
+
type: "secret",
|
|
104
|
+
label: "Password (password sign-in)"
|
|
105
|
+
},
|
|
106
|
+
websiteId: {
|
|
107
|
+
type: "string",
|
|
108
|
+
label: "Website ID",
|
|
109
|
+
description: "Umami → Settings → Websites → Edit. A UUID."
|
|
110
|
+
},
|
|
111
|
+
injectScript: {
|
|
112
|
+
type: "boolean",
|
|
113
|
+
label: "Add the Umami tracker to public pages",
|
|
114
|
+
default: false
|
|
115
|
+
},
|
|
116
|
+
clientRouter: {
|
|
117
|
+
type: "boolean",
|
|
118
|
+
label: "The site uses Astro's <ClientRouter /> (view transitions)",
|
|
119
|
+
description: "Counts every page change, including back and forward, once.",
|
|
120
|
+
default: false
|
|
121
|
+
},
|
|
122
|
+
scriptUrl: {
|
|
123
|
+
type: "url",
|
|
124
|
+
label: "Custom tracker script URL",
|
|
125
|
+
description: "Optional. Default: <Umami URL>/script.js.",
|
|
126
|
+
placeholder: "https://stats.example.com/script.js"
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
function str(value) {
|
|
130
|
+
if (typeof value !== "string") return null;
|
|
131
|
+
const trimmed = value.trim();
|
|
132
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
133
|
+
}
|
|
134
|
+
function oneOf(value, allowed, fallback) {
|
|
135
|
+
return typeof value === "string" && allowed.includes(value) ? value : fallback;
|
|
136
|
+
}
|
|
137
|
+
/** Strips trailing slashes and a trailing /api so users can paste either form. */
|
|
138
|
+
function normalizeBaseUrl(value) {
|
|
139
|
+
if (!value) return null;
|
|
140
|
+
let url;
|
|
141
|
+
try {
|
|
142
|
+
url = new URL(value);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
147
|
+
const path = url.pathname.replace(/\/+$/, "").replace(/\/api$/, "");
|
|
148
|
+
return `${url.origin}${path}`;
|
|
149
|
+
}
|
|
150
|
+
async function readSettings(settings) {
|
|
151
|
+
const keys = Object.keys(settingsSchema);
|
|
152
|
+
const values = await Promise.all(keys.map((key) => settings.get(key)));
|
|
153
|
+
const raw = Object.fromEntries(keys.map((key, i) => [key, values[i]]));
|
|
154
|
+
const hosting = oneOf(raw.hosting, ["self-hosted"], "self-hosted");
|
|
155
|
+
return {
|
|
156
|
+
hosting,
|
|
157
|
+
baseUrl: normalizeBaseUrl(str(raw.baseUrl)),
|
|
158
|
+
region: oneOf(raw.region, [
|
|
159
|
+
"default",
|
|
160
|
+
"us",
|
|
161
|
+
"eu"
|
|
162
|
+
], "default"),
|
|
163
|
+
authMode: hosting === "cloud" ? "apiKey" : oneOf(raw.authMode, ["apiKey", "password"], "apiKey"),
|
|
164
|
+
apiKey: str(raw.apiKey),
|
|
165
|
+
username: str(raw.username),
|
|
166
|
+
password: typeof raw.password === "string" && raw.password.length > 0 ? raw.password : null,
|
|
167
|
+
websiteId: str(raw.websiteId),
|
|
168
|
+
injectScript: raw.injectScript === true,
|
|
169
|
+
clientRouter: raw.clientRouter === true,
|
|
170
|
+
scriptUrl: normalizeScriptUrl(str(raw.scriptUrl))
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function normalizeScriptUrl(value) {
|
|
174
|
+
if (!value) return null;
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(value);
|
|
177
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Names of settings that must be filled in before Umami can be queried. */
|
|
183
|
+
function missingSettings(s) {
|
|
184
|
+
const missing = [];
|
|
185
|
+
if (s.hosting === "self-hosted" && !s.baseUrl) missing.push("baseUrl");
|
|
186
|
+
if (s.authMode === "apiKey" && !s.apiKey) missing.push("apiKey");
|
|
187
|
+
if (s.authMode === "password") {
|
|
188
|
+
if (!s.username) missing.push("username");
|
|
189
|
+
if (!s.password) missing.push("password");
|
|
190
|
+
}
|
|
191
|
+
if (!s.websiteId) missing.push("websiteId");
|
|
192
|
+
return missing;
|
|
193
|
+
}
|
|
194
|
+
/** Where a person views this site's stats in Umami. */
|
|
195
|
+
function dashboardUrl(s) {
|
|
196
|
+
const origin = s.hosting === "cloud" ? "https://cloud.umami.is" : s.baseUrl;
|
|
197
|
+
if (!origin) return null;
|
|
198
|
+
return s.websiteId ? `${origin}/websites/${encodeURIComponent(s.websiteId)}` : origin;
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/shared/stats.ts
|
|
202
|
+
/** Stats shapes shared by the server routes and the admin UI. */
|
|
203
|
+
const RANGES = [
|
|
204
|
+
"24h",
|
|
205
|
+
"7d",
|
|
206
|
+
"30d",
|
|
207
|
+
"90d"
|
|
208
|
+
];
|
|
209
|
+
const METRIC_TYPES = [
|
|
210
|
+
"path",
|
|
211
|
+
"referrer",
|
|
212
|
+
"country",
|
|
213
|
+
"browser",
|
|
214
|
+
"os",
|
|
215
|
+
"device",
|
|
216
|
+
"event"
|
|
217
|
+
];
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/umami/client.ts
|
|
220
|
+
/** Error with a stable code for the UI. Messages never contain credentials. */
|
|
221
|
+
var UmamiError = class extends Error {
|
|
222
|
+
code;
|
|
223
|
+
status;
|
|
224
|
+
constructor(code, message, status, options) {
|
|
225
|
+
super(message, options);
|
|
226
|
+
this.code = code;
|
|
227
|
+
this.status = status;
|
|
228
|
+
this.name = "UmamiError";
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
/** Server-log description of a low-level failure. Fetch errors carry URLs, never our headers. */
|
|
232
|
+
function describeCause(cause) {
|
|
233
|
+
if (cause instanceof Error) {
|
|
234
|
+
const inner = cause.cause instanceof Error ? ` (${cause.cause.name}: ${cause.cause.message})` : "";
|
|
235
|
+
return `${cause.name}: ${cause.message}${inner}`;
|
|
236
|
+
}
|
|
237
|
+
return String(cause);
|
|
238
|
+
}
|
|
239
|
+
const TOKEN_KEY = "state:token";
|
|
240
|
+
const TIMEOUT_MS = 1e4;
|
|
241
|
+
/** API root: `<baseUrl>/api` when self-hosted, `https://api.umami.is/v1[/region]` on Umami Cloud. */
|
|
242
|
+
function apiBase(s) {
|
|
243
|
+
if (s.hosting === "cloud") return s.region === "default" ? "https://api.umami.is/v1" : `https://api.umami.is/v1/${s.region}`;
|
|
244
|
+
return s.baseUrl ? `${s.baseUrl}/api` : null;
|
|
245
|
+
}
|
|
246
|
+
function describeStatus(status, what) {
|
|
247
|
+
if (status === 401) return new UmamiError("unauthorized", "Umami rejected the credentials. Check the API key, or the username and password.", status);
|
|
248
|
+
if (status === 403) return new UmamiError("forbidden", `This Umami account is not allowed to read ${what}.`, status);
|
|
249
|
+
if (status === 404) return new UmamiError("not-found", `Umami could not find ${what}. Check the Umami URL and the website.`, status);
|
|
250
|
+
if (status === 429) return new UmamiError("rate-limited", "Umami is rate limiting requests. Try again in a minute.", status);
|
|
251
|
+
return new UmamiError("upstream", `Umami answered with HTTP ${status} for ${what}.`, status);
|
|
252
|
+
}
|
|
253
|
+
function createUmamiClient(s, deps) {
|
|
254
|
+
const base = apiBase(s);
|
|
255
|
+
if (!base) throw new UmamiError("not-configured", "Add the Umami URL in the Umami settings.");
|
|
256
|
+
if (s.authMode === "apiKey" && !s.apiKey) throw new UmamiError("not-configured", "Add an Umami API key in the Umami settings.");
|
|
257
|
+
if (s.authMode === "password" && (!s.username || !s.password)) throw new UmamiError("not-configured", "Add the Umami username and password in the Umami settings.");
|
|
258
|
+
async function send(url, init, what) {
|
|
259
|
+
try {
|
|
260
|
+
return await deps.fetch(url, {
|
|
261
|
+
...init,
|
|
262
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
263
|
+
});
|
|
264
|
+
} catch (cause) {
|
|
265
|
+
throw new UmamiError("unreachable", `Could not reach Umami at ${new URL(url).origin} while loading ${what}.`, void 0, { cause });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const scope = `${base}|${s.username ?? ""}`;
|
|
269
|
+
async function login() {
|
|
270
|
+
const res = await send(`${base}/auth/login`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: {
|
|
273
|
+
"Content-Type": "application/json",
|
|
274
|
+
Accept: "application/json"
|
|
275
|
+
},
|
|
276
|
+
body: JSON.stringify({
|
|
277
|
+
username: s.username,
|
|
278
|
+
password: s.password
|
|
279
|
+
})
|
|
280
|
+
}, "the login");
|
|
281
|
+
if (!res.ok) throw describeStatus(res.status === 400 ? 401 : res.status, "the login");
|
|
282
|
+
const body = await res.json().catch(() => null);
|
|
283
|
+
if (!body || typeof body.token !== "string") throw new UmamiError("upstream", "Umami's login answer had no token. Is this an Umami server?");
|
|
284
|
+
await deps.kv.set(TOKEN_KEY, {
|
|
285
|
+
token: body.token,
|
|
286
|
+
scope
|
|
287
|
+
});
|
|
288
|
+
return body.token;
|
|
289
|
+
}
|
|
290
|
+
async function bearer(forceLogin) {
|
|
291
|
+
if (s.authMode === "apiKey") return s.apiKey;
|
|
292
|
+
if (!forceLogin) {
|
|
293
|
+
const cached = await deps.kv.get(TOKEN_KEY);
|
|
294
|
+
if (cached && cached.scope === scope) return cached.token;
|
|
295
|
+
}
|
|
296
|
+
return login();
|
|
297
|
+
}
|
|
298
|
+
return { async get(path, query = {}, what = "the data") {
|
|
299
|
+
const url = new URL(`${base}${path}`);
|
|
300
|
+
for (const [key, value] of Object.entries(query)) if (value !== void 0) url.searchParams.set(key, String(value));
|
|
301
|
+
const request = async (forceLogin) => send(url.toString(), { headers: {
|
|
302
|
+
Authorization: `Bearer ${await bearer(forceLogin)}`,
|
|
303
|
+
Accept: "application/json"
|
|
304
|
+
} }, what);
|
|
305
|
+
let res = await request(false);
|
|
306
|
+
if (res.status === 401 && s.authMode === "password") {
|
|
307
|
+
await deps.kv.delete(TOKEN_KEY);
|
|
308
|
+
res = await request(true);
|
|
309
|
+
}
|
|
310
|
+
if (!res.ok) throw describeStatus(res.status, what);
|
|
311
|
+
try {
|
|
312
|
+
return await res.json();
|
|
313
|
+
} catch {
|
|
314
|
+
throw new UmamiError("upstream", `Umami's answer for ${what} was not JSON. Is the Umami URL right?`);
|
|
315
|
+
}
|
|
316
|
+
} };
|
|
317
|
+
}
|
|
318
|
+
function toWebsite(w) {
|
|
319
|
+
if (typeof w.id !== "string") return null;
|
|
320
|
+
return {
|
|
321
|
+
id: w.id,
|
|
322
|
+
name: typeof w.name === "string" && w.name ? w.name : w.id,
|
|
323
|
+
domain: typeof w.domain === "string" && w.domain ? w.domain : null
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Websites the credentials can read, sorted by name: the user's own and those of teams they own or
|
|
328
|
+
* manage (`/websites?includeTeams`), plus every team they belong to in any role (`/teams/{id}/websites`),
|
|
329
|
+
* which covers sites shared with a plain team member.
|
|
330
|
+
*/
|
|
331
|
+
async function listWebsites(client) {
|
|
332
|
+
const [own, teams] = await Promise.all([client.get("/websites", {
|
|
333
|
+
includeTeams: true,
|
|
334
|
+
pageSize: 200
|
|
335
|
+
}, "the website list"), client.get("/teams", { pageSize: 100 }, "the team list").catch(() => ({ data: [] }))]);
|
|
336
|
+
const teamIds = (teams.data ?? []).map((t) => t.id).filter((id) => typeof id === "string");
|
|
337
|
+
const teamSites = await Promise.all(teamIds.map((id) => client.get(`/teams/${encodeURIComponent(id)}/websites`, { pageSize: 200 }, "a team's websites").catch(() => ({ data: [] }))));
|
|
338
|
+
const byId = /* @__PURE__ */ new Map();
|
|
339
|
+
for (const raw of [...own.data ?? [], ...teamSites.flatMap((page) => page.data ?? [])]) {
|
|
340
|
+
const site = toWebsite(raw);
|
|
341
|
+
if (site && !byId.has(site.id)) byId.set(site.id, site);
|
|
342
|
+
}
|
|
343
|
+
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
344
|
+
}
|
|
345
|
+
/** One website by id, or null when Umami says it does not exist or is not visible. */
|
|
346
|
+
async function getWebsite(client, id) {
|
|
347
|
+
try {
|
|
348
|
+
return toWebsite(await client.get(`/websites/${encodeURIComponent(id)}`, {}, "the website"));
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error instanceof UmamiError && (error.code === "not-found" || error.code === "forbidden")) return null;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/umami/stats.ts
|
|
356
|
+
const HOUR = 36e5;
|
|
357
|
+
const DAY = 24 * HOUR;
|
|
358
|
+
/** Rolling windows ending now; hours for the last day, days otherwise. */
|
|
359
|
+
function windowFor(range, now) {
|
|
360
|
+
const days = {
|
|
361
|
+
"24h": 1,
|
|
362
|
+
"7d": 7,
|
|
363
|
+
"30d": 30,
|
|
364
|
+
"90d": 90
|
|
365
|
+
}[range];
|
|
366
|
+
return {
|
|
367
|
+
range,
|
|
368
|
+
unit: range === "24h" ? "hour" : "day",
|
|
369
|
+
startAt: now - days * DAY + 1,
|
|
370
|
+
endAt: now
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function isValidTimeZone(tz) {
|
|
374
|
+
try {
|
|
375
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
376
|
+
return true;
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function num(value) {
|
|
382
|
+
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
|
383
|
+
return Number.isFinite(n) ? n : 0;
|
|
384
|
+
}
|
|
385
|
+
function toTotals(raw) {
|
|
386
|
+
const r = raw ?? {};
|
|
387
|
+
return {
|
|
388
|
+
pageviews: num(r.pageviews),
|
|
389
|
+
visitors: num(r.visitors),
|
|
390
|
+
visits: num(r.visits),
|
|
391
|
+
bounces: num(r.bounces),
|
|
392
|
+
totaltime: num(r.totaltime)
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
const formatters = /* @__PURE__ */ new Map();
|
|
396
|
+
function formatter(tz) {
|
|
397
|
+
let f = formatters.get(tz);
|
|
398
|
+
if (!f) {
|
|
399
|
+
f = new Intl.DateTimeFormat("en-CA", {
|
|
400
|
+
timeZone: tz,
|
|
401
|
+
year: "numeric",
|
|
402
|
+
month: "2-digit",
|
|
403
|
+
day: "2-digit",
|
|
404
|
+
hour: "2-digit",
|
|
405
|
+
hourCycle: "h23"
|
|
406
|
+
});
|
|
407
|
+
formatters.set(tz, f);
|
|
408
|
+
}
|
|
409
|
+
return f;
|
|
410
|
+
}
|
|
411
|
+
/** Wall-clock parts of an instant in a time zone. */
|
|
412
|
+
function partsIn(t, tz) {
|
|
413
|
+
const parts = Object.fromEntries(formatter(tz).formatToParts(new Date(t)).map((p) => [p.type, p.value]));
|
|
414
|
+
return {
|
|
415
|
+
date: `${parts.year}-${parts.month}-${parts.day}`,
|
|
416
|
+
hour: parts.hour
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
function bucketLabel(t, unit, tz) {
|
|
420
|
+
const p = partsIn(t, tz);
|
|
421
|
+
return unit === "day" ? p.date : `${p.date} ${p.hour}:00`;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Umami returns buckets as wall-clock strings in the requested zone ("2026-09-28 14:00:00", or an
|
|
425
|
+
* ISO-like variant) and omits empty ones. Normalize to our label format.
|
|
426
|
+
*/
|
|
427
|
+
function labelFromUmami(x, unit) {
|
|
428
|
+
const clean = x.replace("T", " ");
|
|
429
|
+
return unit === "day" ? clean.slice(0, 10) : `${clean.slice(0, 13)}:00`;
|
|
430
|
+
}
|
|
431
|
+
/** Every bucket in the window, oldest first, so charts show zero days instead of skipping them. */
|
|
432
|
+
function buckets(win, tz) {
|
|
433
|
+
const step = HOUR;
|
|
434
|
+
const seen = /* @__PURE__ */ new Map();
|
|
435
|
+
for (let t = Math.floor(win.startAt / HOUR) * HOUR; t <= win.endAt; t += step) {
|
|
436
|
+
const label = bucketLabel(t, win.unit, tz);
|
|
437
|
+
if (!seen.has(label)) seen.set(label, t);
|
|
438
|
+
}
|
|
439
|
+
const end = bucketLabel(win.endAt, win.unit, tz);
|
|
440
|
+
if (!seen.has(end)) seen.set(end, win.endAt);
|
|
441
|
+
return [...seen.entries()].map(([label, t]) => ({
|
|
442
|
+
label,
|
|
443
|
+
t
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
function mergeSeries(win, tz, pageviews, sessions) {
|
|
447
|
+
const index = (rows) => {
|
|
448
|
+
const m = /* @__PURE__ */ new Map();
|
|
449
|
+
for (const row of rows ?? []) {
|
|
450
|
+
if (typeof row.x !== "string") continue;
|
|
451
|
+
const key = labelFromUmami(row.x, win.unit);
|
|
452
|
+
m.set(key, (m.get(key) ?? 0) + num(row.y));
|
|
453
|
+
}
|
|
454
|
+
return m;
|
|
455
|
+
};
|
|
456
|
+
const pv = index(pageviews);
|
|
457
|
+
const vis = index(sessions);
|
|
458
|
+
return buckets(win, tz).map(({ label, t }) => ({
|
|
459
|
+
label,
|
|
460
|
+
t,
|
|
461
|
+
pageviews: pv.get(label) ?? 0,
|
|
462
|
+
visitors: vis.get(label) ?? 0
|
|
463
|
+
}));
|
|
464
|
+
}
|
|
465
|
+
function toRows(raw, limit) {
|
|
466
|
+
if (!Array.isArray(raw)) return [];
|
|
467
|
+
return raw.map((row) => ({
|
|
468
|
+
label: row.x == null || row.x === "" ? "(none)" : String(row.x),
|
|
469
|
+
value: num(row.y)
|
|
470
|
+
})).filter((row) => row.value > 0).slice(0, limit);
|
|
471
|
+
}
|
|
472
|
+
const TOP_LIMIT = 10;
|
|
473
|
+
async function fetchOverview(client, websiteId, range, timezone, now = Date.now()) {
|
|
474
|
+
const win = windowFor(range, now);
|
|
475
|
+
const id = encodeURIComponent(websiteId);
|
|
476
|
+
const q = {
|
|
477
|
+
startAt: win.startAt,
|
|
478
|
+
endAt: win.endAt,
|
|
479
|
+
timezone
|
|
480
|
+
};
|
|
481
|
+
const length = win.endAt - win.startAt;
|
|
482
|
+
const [stats, prev, series, ...metrics] = await Promise.all([
|
|
483
|
+
client.get(`/websites/${id}/stats`, q, "the summary"),
|
|
484
|
+
client.get(`/websites/${id}/stats`, {
|
|
485
|
+
...q,
|
|
486
|
+
startAt: win.startAt - length - 1,
|
|
487
|
+
endAt: win.startAt - 1
|
|
488
|
+
}, "the previous period"),
|
|
489
|
+
client.get(`/websites/${id}/pageviews`, {
|
|
490
|
+
...q,
|
|
491
|
+
unit: win.unit
|
|
492
|
+
}, "the chart"),
|
|
493
|
+
...METRIC_TYPES.map((type) => client.get(`/websites/${id}/metrics`, {
|
|
494
|
+
...q,
|
|
495
|
+
type,
|
|
496
|
+
limit: TOP_LIMIT
|
|
497
|
+
}, `the top ${type} list`))
|
|
498
|
+
]);
|
|
499
|
+
return {
|
|
500
|
+
websiteId,
|
|
501
|
+
range,
|
|
502
|
+
unit: win.unit,
|
|
503
|
+
timezone,
|
|
504
|
+
startAt: win.startAt,
|
|
505
|
+
endAt: win.endAt,
|
|
506
|
+
totals: toTotals(stats),
|
|
507
|
+
previous: toTotals(prev),
|
|
508
|
+
series: mergeSeries(win, timezone, series.pageviews ?? [], series.sessions ?? []),
|
|
509
|
+
top: Object.fromEntries(METRIC_TYPES.map((type, i) => [type, toRows(metrics[i], TOP_LIMIT)])),
|
|
510
|
+
fetchedAt: now,
|
|
511
|
+
stale: false
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
async function fetchActive(client, websiteId, now = Date.now()) {
|
|
515
|
+
return {
|
|
516
|
+
visitors: num((await client.get(`/websites/${encodeURIComponent(websiteId)}/active`, {}, "active visitors"))?.visitors),
|
|
517
|
+
fetchedAt: now
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/routes/stats.ts
|
|
522
|
+
const overviewInput = z.object({
|
|
523
|
+
range: z.enum(RANGES).default("7d"),
|
|
524
|
+
tz: z.string().max(64).optional().transform((tz) => tz && isValidTimeZone(tz) ? tz : "UTC")
|
|
525
|
+
});
|
|
526
|
+
/** 24h data moves quickly; longer ranges barely change minute to minute. */
|
|
527
|
+
const OVERVIEW_MAX_AGE = {
|
|
528
|
+
"24h": 6e4,
|
|
529
|
+
"7d": 3e5,
|
|
530
|
+
"30d": 6e5,
|
|
531
|
+
"90d": 9e5
|
|
532
|
+
};
|
|
533
|
+
const ACTIVE_MAX_AGE = 2e4;
|
|
534
|
+
async function withClient(ctx, run) {
|
|
535
|
+
try {
|
|
536
|
+
const s = await readSettings(ctx.settings);
|
|
537
|
+
if (!s.websiteId) throw new UmamiError("not-configured", "Choose the Umami website in the plugin settings.");
|
|
538
|
+
const http = ctx.http;
|
|
539
|
+
if (!http) throw new UmamiError("unreachable", "EmDash did not grant this plugin network access.");
|
|
540
|
+
return {
|
|
541
|
+
ok: true,
|
|
542
|
+
data: await run(createUmamiClient(s, {
|
|
543
|
+
fetch: (url, init) => http.fetch(url, init),
|
|
544
|
+
kv: ctx.kv
|
|
545
|
+
}), s.websiteId)
|
|
546
|
+
};
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (error instanceof UmamiError) {
|
|
549
|
+
if (error.cause) ctx.log.warn("Umami request failed", {
|
|
550
|
+
code: error.code,
|
|
551
|
+
cause: describeCause(error.cause)
|
|
552
|
+
});
|
|
553
|
+
return {
|
|
554
|
+
ok: false,
|
|
555
|
+
code: error.code,
|
|
556
|
+
message: error.message
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
ctx.log.error("Umami request failed", { error: error instanceof Error ? error.message : String(error) });
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
code: "upstream",
|
|
563
|
+
message: "Loading statistics failed unexpectedly. See the server log."
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function overview(ctx) {
|
|
568
|
+
const { range, tz } = ctx.input;
|
|
569
|
+
return withClient(ctx, async (client, websiteId) => {
|
|
570
|
+
const hit = await cached(ctx.kv, `cache:overview:${websiteId}:${range}:${tz}`, OVERVIEW_MAX_AGE[range], () => fetchOverview(client, websiteId, range, tz));
|
|
571
|
+
return {
|
|
572
|
+
...hit.value,
|
|
573
|
+
stale: hit.stale
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
function active(ctx) {
|
|
578
|
+
return withClient(ctx, async (client, websiteId) => {
|
|
579
|
+
return (await cached(ctx.kv, `cache:active:${websiteId}`, ACTIVE_MAX_AGE, () => fetchActive(client, websiteId))).value;
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
//#endregion
|
|
583
|
+
//#region src/routes/settings.ts
|
|
584
|
+
function view(s) {
|
|
585
|
+
const { apiKey, password, ...values } = s;
|
|
586
|
+
return {
|
|
587
|
+
values,
|
|
588
|
+
secretsSet: {
|
|
589
|
+
apiKey: apiKey !== null,
|
|
590
|
+
password: password !== null
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
async function getSettings(ctx) {
|
|
595
|
+
return view(await readSettings(ctx.settings));
|
|
596
|
+
}
|
|
597
|
+
const optionalText = z.string().trim().max(2048).nullable().optional();
|
|
598
|
+
/**
|
|
599
|
+
* URL fields are validated in the input schema, not by throwing from the handler: in `astro dev` a
|
|
600
|
+
* PluginRouteError thrown here is a different class instance from the host's and surfaces as a
|
|
601
|
+
* generic INTERNAL_ERROR, while schema failures always come back as VALIDATION_ERROR.
|
|
602
|
+
*/
|
|
603
|
+
const optionalUrl = optionalText.refine((value) => !value || normalizeBaseUrl(value) !== null, { message: "Must be an address starting with https:// or http://" });
|
|
604
|
+
/** Secrets: a string replaces the stored value, null clears it, omitted keeps it. */
|
|
605
|
+
const secretInput = z.string().max(4096).nullable().optional();
|
|
606
|
+
const saveSettingsInput = z.object({
|
|
607
|
+
hosting: z.enum(["self-hosted", "cloud"]).refine((h) => h !== "cloud", { message: "Umami Cloud support arrives in 0.2.0" }).optional(),
|
|
608
|
+
baseUrl: optionalUrl,
|
|
609
|
+
region: z.enum([
|
|
610
|
+
"default",
|
|
611
|
+
"us",
|
|
612
|
+
"eu"
|
|
613
|
+
]).optional(),
|
|
614
|
+
authMode: z.enum(["apiKey", "password"]).optional(),
|
|
615
|
+
apiKey: secretInput,
|
|
616
|
+
username: optionalText,
|
|
617
|
+
password: secretInput,
|
|
618
|
+
websiteId: optionalText,
|
|
619
|
+
injectScript: z.boolean().optional(),
|
|
620
|
+
clientRouter: z.boolean().optional(),
|
|
621
|
+
scriptUrl: optionalUrl
|
|
622
|
+
});
|
|
623
|
+
async function saveSettings(ctx) {
|
|
624
|
+
const input = ctx.input;
|
|
625
|
+
const writes = [];
|
|
626
|
+
const put = (key, value) => {
|
|
627
|
+
if (value === void 0) return;
|
|
628
|
+
if (value === null || value === "") writes.push(ctx.settings.delete(key));
|
|
629
|
+
else writes.push(ctx.settings.set(key, value));
|
|
630
|
+
};
|
|
631
|
+
put("hosting", input.hosting);
|
|
632
|
+
put("baseUrl", input.baseUrl === void 0 ? void 0 : normalizeBaseUrl(input.baseUrl ?? null));
|
|
633
|
+
put("region", input.region);
|
|
634
|
+
put("authMode", input.authMode);
|
|
635
|
+
put("apiKey", input.apiKey === void 0 ? void 0 : input.apiKey?.trim() || null);
|
|
636
|
+
put("username", input.username);
|
|
637
|
+
put("password", input.password);
|
|
638
|
+
put("websiteId", input.websiteId);
|
|
639
|
+
put("injectScript", input.injectScript);
|
|
640
|
+
put("clientRouter", input.clientRouter);
|
|
641
|
+
put("scriptUrl", input.scriptUrl);
|
|
642
|
+
await Promise.all(writes);
|
|
643
|
+
if (input.baseUrl !== void 0 || input.username !== void 0 || input.password !== void 0 || input.hosting !== void 0) await ctx.kv.delete(TOKEN_KEY);
|
|
644
|
+
return getSettings(ctx);
|
|
645
|
+
}
|
|
646
|
+
function clientFor(ctx, s) {
|
|
647
|
+
const http = ctx.http;
|
|
648
|
+
if (!http) throw new UmamiError("unreachable", "EmDash did not grant this plugin network access.");
|
|
649
|
+
return createUmamiClient(s, {
|
|
650
|
+
fetch: (url, init) => http.fetch(url, init),
|
|
651
|
+
kv: ctx.kv
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
/** Checks the saved settings against Umami. Returns failures as data so the UI can explain them. */
|
|
655
|
+
async function testConnection(ctx) {
|
|
656
|
+
const s = await readSettings(ctx.settings);
|
|
657
|
+
try {
|
|
658
|
+
const client = clientFor(ctx, s);
|
|
659
|
+
const websites = await listWebsites(client);
|
|
660
|
+
let website = websites.find((w) => w.id === s.websiteId) ?? null;
|
|
661
|
+
if (!website && s.websiteId) {
|
|
662
|
+
website = await getWebsite(client, s.websiteId);
|
|
663
|
+
if (website) websites.push(website);
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
ok: true,
|
|
667
|
+
websites,
|
|
668
|
+
website
|
|
669
|
+
};
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (error instanceof UmamiError) {
|
|
672
|
+
if (error.cause) ctx.log.warn("Umami request failed", {
|
|
673
|
+
code: error.code,
|
|
674
|
+
cause: describeCause(error.cause)
|
|
675
|
+
});
|
|
676
|
+
return {
|
|
677
|
+
ok: false,
|
|
678
|
+
code: error.code,
|
|
679
|
+
message: error.message
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
ctx.log.error("Umami connection test failed", { error: error instanceof Error ? error.message : String(error) });
|
|
683
|
+
return {
|
|
684
|
+
ok: false,
|
|
685
|
+
code: "upstream",
|
|
686
|
+
message: "The connection test failed unexpectedly. See the server log."
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
//#endregion
|
|
691
|
+
//#region src/shared/tracker.ts
|
|
692
|
+
const CLOUD_SCRIPT_URL = "https://cloud.umami.is/script.js";
|
|
693
|
+
/** The tracker script a site should load: the custom URL if set, else the hosting default. */
|
|
694
|
+
function defaultScriptUrl(hosting, baseUrl, scriptUrl) {
|
|
695
|
+
if (scriptUrl) return scriptUrl;
|
|
696
|
+
if (hosting === "cloud") return CLOUD_SCRIPT_URL;
|
|
697
|
+
return baseUrl ? `${baseUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/script.js` : null;
|
|
698
|
+
}
|
|
699
|
+
//#endregion
|
|
700
|
+
//#region src/tracker.ts
|
|
701
|
+
/**
|
|
702
|
+
* Pageviews for sites using Astro's <ClientRouter />. Umami's own tracking hooks pushState only,
|
|
703
|
+
* so back and forward are missed, and a re-run tracker tag counts forward navigations twice.
|
|
704
|
+
* With `data-auto-pageview="false"` this sends one pageview per `astro:page-load` instead,
|
|
705
|
+
* passing the URL explicitly because Umami's stored URL is stale after a back navigation. On a
|
|
706
|
+
* page without <ClientRouter /> (no `astro-view-transitions-enabled` meta) it tracks once on
|
|
707
|
+
* DOMContentLoaded, so a wrong setting never stops tracking.
|
|
708
|
+
*/
|
|
709
|
+
const CLIENT_ROUTER_SCRIPT = `(()=>{let prev;const send=()=>{const u=window.umami;if(!u)return false;const url=location.href;u.track((p)=>({...p,url,title:document.title,...(prev?{referrer:prev}:{})}));prev=location.pathname+location.search;return true};const track=()=>{let n=0;const t=()=>{send()||++n>100||setTimeout(t,50)};t()};document.addEventListener("astro:page-load",track);document.addEventListener("DOMContentLoaded",()=>{document.querySelector('meta[name="astro-view-transitions-enabled"]')||track()})})();`;
|
|
710
|
+
/**
|
|
711
|
+
* The Umami tracker as page fragments, or null when tracking is off or incomplete.
|
|
712
|
+
* EmDash HTML-escapes attribute values and drops on* attributes when rendering.
|
|
713
|
+
*/
|
|
714
|
+
async function trackerFragment(event, settings) {
|
|
715
|
+
if (event.page.path.startsWith("/_emdash")) return null;
|
|
716
|
+
if (await settings.get("injectScript") !== true) return null;
|
|
717
|
+
const s = await readSettings(settings);
|
|
718
|
+
const src = defaultScriptUrl(s.hosting, s.baseUrl, s.scriptUrl);
|
|
719
|
+
if (!src || !s.websiteId || !normalizeBaseUrl(src)) return null;
|
|
720
|
+
const tracker = {
|
|
721
|
+
kind: "external-script",
|
|
722
|
+
placement: "head",
|
|
723
|
+
src,
|
|
724
|
+
defer: true,
|
|
725
|
+
attributes: { "data-website-id": s.websiteId },
|
|
726
|
+
key: "umami-tracker"
|
|
727
|
+
};
|
|
728
|
+
if (!s.clientRouter) return tracker;
|
|
729
|
+
return [{
|
|
730
|
+
...tracker,
|
|
731
|
+
attributes: {
|
|
732
|
+
...tracker.attributes,
|
|
733
|
+
"data-auto-pageview": "false"
|
|
734
|
+
}
|
|
735
|
+
}, {
|
|
736
|
+
kind: "inline-script",
|
|
737
|
+
placement: "head",
|
|
738
|
+
code: CLIENT_ROUTER_SCRIPT,
|
|
739
|
+
key: "umami-client-router"
|
|
740
|
+
}];
|
|
741
|
+
}
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/index.ts
|
|
744
|
+
const ADMIN_ENTRY = `${PACKAGE_NAME}/admin`;
|
|
745
|
+
/**
|
|
746
|
+
* Descriptor for astro.config.mjs:
|
|
747
|
+
*
|
|
748
|
+
* import { umamiPlugin } from "@tdhftw/emdash-plugin-umami";
|
|
749
|
+
* emdash({ plugins: [umamiPlugin()] })
|
|
750
|
+
*/
|
|
751
|
+
function umamiPlugin(options = {}) {
|
|
752
|
+
return {
|
|
753
|
+
id: PLUGIN_ID,
|
|
754
|
+
version: PLUGIN_VERSION,
|
|
755
|
+
format: "native",
|
|
756
|
+
entrypoint: PACKAGE_NAME,
|
|
757
|
+
adminEntry: ADMIN_ENTRY,
|
|
758
|
+
options
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
async function status(ctx) {
|
|
762
|
+
const s = await readSettings(ctx.settings);
|
|
763
|
+
const missing = missingSettings(s);
|
|
764
|
+
return {
|
|
765
|
+
configured: missing.length === 0,
|
|
766
|
+
missing,
|
|
767
|
+
hosting: s.hosting,
|
|
768
|
+
authMode: s.authMode,
|
|
769
|
+
websiteId: s.websiteId,
|
|
770
|
+
dashboardUrl: dashboardUrl(s),
|
|
771
|
+
injectScript: s.injectScript,
|
|
772
|
+
hasHttp: Boolean(ctx.http)
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
/** Runtime definition. EmDash imports this named export from the package root. */
|
|
776
|
+
function createPlugin(_options = {}) {
|
|
777
|
+
return definePlugin({
|
|
778
|
+
id: PLUGIN_ID,
|
|
779
|
+
version: PLUGIN_VERSION,
|
|
780
|
+
capabilities: ["network:request:unrestricted", "hooks.page-fragments:register"],
|
|
781
|
+
hooks: { "page:fragments": (event, ctx) => trackerFragment(event, ctx.settings) },
|
|
782
|
+
admin: {
|
|
783
|
+
entry: ADMIN_ENTRY,
|
|
784
|
+
settingsSchema,
|
|
785
|
+
pages: [{
|
|
786
|
+
path: "/",
|
|
787
|
+
label: "Analytics",
|
|
788
|
+
icon: "chart-line"
|
|
789
|
+
}],
|
|
790
|
+
widgets: [{
|
|
791
|
+
id: "umami-overview",
|
|
792
|
+
title: "Visitors",
|
|
793
|
+
size: "half"
|
|
794
|
+
}]
|
|
795
|
+
},
|
|
796
|
+
routes: {
|
|
797
|
+
status: {
|
|
798
|
+
permission: "plugins:read",
|
|
799
|
+
handler: status
|
|
800
|
+
},
|
|
801
|
+
overview: {
|
|
802
|
+
permission: "plugins:read",
|
|
803
|
+
input: overviewInput,
|
|
804
|
+
handler: overview
|
|
805
|
+
},
|
|
806
|
+
active: {
|
|
807
|
+
permission: "plugins:read",
|
|
808
|
+
handler: active
|
|
809
|
+
},
|
|
810
|
+
settings: {
|
|
811
|
+
permission: "plugins:manage",
|
|
812
|
+
handler: getSettings
|
|
813
|
+
},
|
|
814
|
+
"settings/save": {
|
|
815
|
+
permission: "plugins:manage",
|
|
816
|
+
input: saveSettingsInput,
|
|
817
|
+
handler: saveSettings
|
|
818
|
+
},
|
|
819
|
+
"connection/test": {
|
|
820
|
+
permission: "plugins:manage",
|
|
821
|
+
handler: testConnection
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
//#endregion
|
|
827
|
+
export { PLUGIN_ID, PLUGIN_VERSION, createPlugin, createPlugin as default, umamiPlugin };
|