@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/package.json
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tdhftw/emdash-plugin-umami",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Umami analytics inside the EmDash CMS admin: an Analytics page, a dashboard widget and optional tracker injection, for self-hosted Umami",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./admin": "./src/admin/index.tsx"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src/admin",
|
|
18
|
+
"src/shared",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsdown",
|
|
24
|
+
"dev": "tsdown --watch",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest",
|
|
28
|
+
"pack:check": "npm pack --dry-run",
|
|
29
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
30
|
+
"prepare": "tsdown"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@cloudflare/kumo": "^2.0.0",
|
|
34
|
+
"@emdash-cms/admin": "^1.0.0",
|
|
35
|
+
"@tanstack/react-query": "^5.0.0",
|
|
36
|
+
"emdash": "^1.0.0",
|
|
37
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
38
|
+
"astro": ">=7.0.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"echarts": "^6.1.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@cloudflare/kumo": "2.6.0",
|
|
45
|
+
"@emdash-cms/admin": "1.0.1",
|
|
46
|
+
"@tanstack/react-query": "5.90.21",
|
|
47
|
+
"@types/react": "19.3.0",
|
|
48
|
+
"astro": "7.3.5",
|
|
49
|
+
"emdash": "1.0.1",
|
|
50
|
+
"react": "19.2.4",
|
|
51
|
+
"react-dom": "19.2.4",
|
|
52
|
+
"tsdown": "^0.23.0",
|
|
53
|
+
"typescript": "~6.0.3",
|
|
54
|
+
"vitest": "^5.0.2"
|
|
55
|
+
},
|
|
56
|
+
"keywords": [
|
|
57
|
+
"emdash",
|
|
58
|
+
"emdash-plugin",
|
|
59
|
+
"umami",
|
|
60
|
+
"analytics"
|
|
61
|
+
],
|
|
62
|
+
"author": "Thord D. Hedengren <tdh@tdh.se>",
|
|
63
|
+
"license": "MIT",
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "git+https://github.com/tdhftw/emdash-umami.git"
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://github.com/tdhftw/emdash-umami#readme",
|
|
69
|
+
"bugs": "https://github.com/tdhftw/emdash-umami/issues",
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
},
|
|
73
|
+
"engines": {
|
|
74
|
+
"node": ">=22"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Banner, Button, Loader, Tabs } from "@cloudflare/kumo";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import type { StatusResponse } from "../shared/constants.js";
|
|
6
|
+
import { callRoute } from "./api.js";
|
|
7
|
+
import { OverviewView } from "./OverviewView.js";
|
|
8
|
+
import { SettingsView } from "./SettingsView.js";
|
|
9
|
+
|
|
10
|
+
type Tab = "overview" | "settings";
|
|
11
|
+
|
|
12
|
+
function tabFromHash(): Tab | null {
|
|
13
|
+
if (typeof window === "undefined") return null;
|
|
14
|
+
return window.location.hash === "#settings" ? "settings" : window.location.hash === "#overview" ? "overview" : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function AnalyticsPage() {
|
|
18
|
+
const status = useQuery({
|
|
19
|
+
queryKey: ["plugin-umami", "status"],
|
|
20
|
+
queryFn: () => callRoute<StatusResponse>("status"),
|
|
21
|
+
});
|
|
22
|
+
const [tab, setTab] = React.useState<Tab | null>(tabFromHash);
|
|
23
|
+
|
|
24
|
+
// Until Umami is connected, the settings are the useful view.
|
|
25
|
+
const active: Tab = tab ?? (status.data && !status.data.configured ? "settings" : "overview");
|
|
26
|
+
const choose = (next: Tab) => {
|
|
27
|
+
setTab(next);
|
|
28
|
+
window.history.replaceState(null, "", `#${next}`);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<section className="space-y-6">
|
|
33
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
34
|
+
<h1 className="text-2xl font-semibold">Analytics</h1>
|
|
35
|
+
{status.data?.dashboardUrl ? (
|
|
36
|
+
<a className="text-sm underline" href={status.data.dashboardUrl} target="_blank" rel="noreferrer">
|
|
37
|
+
Open in Umami
|
|
38
|
+
</a>
|
|
39
|
+
) : null}
|
|
40
|
+
</div>
|
|
41
|
+
<Tabs
|
|
42
|
+
variant="underline"
|
|
43
|
+
value={active}
|
|
44
|
+
onValueChange={(value) => choose(value as Tab)}
|
|
45
|
+
tabs={[
|
|
46
|
+
{ value: "overview", label: "Overview" },
|
|
47
|
+
{ value: "settings", label: "Settings" },
|
|
48
|
+
]}
|
|
49
|
+
/>
|
|
50
|
+
{active === "settings" ? (
|
|
51
|
+
<SettingsView />
|
|
52
|
+
) : status.isLoading ? (
|
|
53
|
+
<Loader />
|
|
54
|
+
) : status.error ? (
|
|
55
|
+
<Banner variant="error" title="Could not load the plugin status" description={(status.error as Error).message} />
|
|
56
|
+
) : status.data && !status.data.configured ? (
|
|
57
|
+
<Banner
|
|
58
|
+
title="Umami is not connected yet"
|
|
59
|
+
description={
|
|
60
|
+
<>
|
|
61
|
+
Finish the setup to see statistics here.{" "}
|
|
62
|
+
<Button type="button" variant="secondary" size="sm" onClick={() => choose("settings")}>
|
|
63
|
+
Open settings
|
|
64
|
+
</Button>
|
|
65
|
+
</>
|
|
66
|
+
}
|
|
67
|
+
/>
|
|
68
|
+
) : (
|
|
69
|
+
<OverviewView onOpenSettings={() => choose("settings")} />
|
|
70
|
+
)}
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Loaded lazily (see OverviewView) so ECharts is only downloaded on the Analytics page.
|
|
2
|
+
// Keep this module's imports limited to ECharts and Kumo's chart: importing a module that the
|
|
3
|
+
// eagerly loaded admin code also uses lets the bundler pull ECharts into that shared chunk.
|
|
4
|
+
// Kumo's TimeseriesChart always stacks bars and uses a time axis with ticks in the browser's zone,
|
|
5
|
+
// so this uses the low-level Chart with options from chartOptions.ts.
|
|
6
|
+
import { Chart as KumoChart, ChartPalette } from "@cloudflare/kumo/components/chart";
|
|
7
|
+
import { BarChart } from "echarts/charts";
|
|
8
|
+
import { AriaComponent, GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
|
9
|
+
import * as echarts from "echarts/core";
|
|
10
|
+
import { CanvasRenderer } from "echarts/renderers";
|
|
11
|
+
|
|
12
|
+
import type { Overview } from "../shared/stats.js";
|
|
13
|
+
import { chartOptions } from "./chartOptions.js";
|
|
14
|
+
|
|
15
|
+
echarts.use([BarChart, AriaComponent, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
|
|
16
|
+
|
|
17
|
+
export default function Chart({ overview, dark }: { overview: Overview; dark: boolean }) {
|
|
18
|
+
const options = chartOptions(overview, {
|
|
19
|
+
pageviews: ChartPalette.categorical(0, dark),
|
|
20
|
+
visitors: ChartPalette.categorical(4, dark),
|
|
21
|
+
});
|
|
22
|
+
return <KumoChart echarts={echarts} options={options} isDarkMode={dark} height={260} optionUpdateBehavior={{ notMerge: true }} />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Badge, Banner, Button, Loader, Surface, Tabs } from "@cloudflare/kumo";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { METRIC_TYPES, RANGES, type MetricRow, type MetricType, type Overview, type Range } from "../shared/stats.js";
|
|
5
|
+
import { METRIC_TITLES, RANGE_LABELS, change, kpis, metricLabel, relativeTime, type Kpi } from "./format.js";
|
|
6
|
+
import { useActive, useDarkMode, useOverview, useRange } from "./hooks.js";
|
|
7
|
+
|
|
8
|
+
const Chart = React.lazy(() => import("./Chart.js"));
|
|
9
|
+
|
|
10
|
+
function Delta({ kpi }: { kpi: Kpi }) {
|
|
11
|
+
const c = change(kpi.value, kpi.previous);
|
|
12
|
+
if (c === null || !Number.isFinite(c)) return <span className="text-xs text-kumo-subtle">no earlier data</span>;
|
|
13
|
+
const rounded = Math.round(c * 100);
|
|
14
|
+
if (rounded === 0) return <span className="text-xs text-kumo-subtle">no change</span>;
|
|
15
|
+
const good = kpi.lowerIsBetter ? rounded < 0 : rounded > 0;
|
|
16
|
+
return (
|
|
17
|
+
<span className={`text-xs tabular-nums ${good ? "text-kumo-success" : "text-kumo-danger"}`}>
|
|
18
|
+
{rounded > 0 ? "▲" : "▼"} {Math.abs(rounded)}%
|
|
19
|
+
</span>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function Tile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
|
|
24
|
+
return (
|
|
25
|
+
<Surface className="rounded-lg p-4">
|
|
26
|
+
<div className="text-sm text-kumo-subtle">{label}</div>
|
|
27
|
+
<div className="text-2xl font-semibold tabular-nums">{value}</div>
|
|
28
|
+
<div>{children}</div>
|
|
29
|
+
</Surface>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ActiveTile() {
|
|
34
|
+
const active = useActive();
|
|
35
|
+
return (
|
|
36
|
+
<Tile label="Active now" value={active.data ? String(active.data.visitors) : active.isError ? "–" : "…"}>
|
|
37
|
+
<span className="text-xs text-kumo-subtle">last 5 minutes</span>
|
|
38
|
+
</Tile>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function TopList({ type, rows }: { type: MetricType; rows: MetricRow[] }) {
|
|
43
|
+
const max = rows[0]?.value ?? 0;
|
|
44
|
+
return (
|
|
45
|
+
<Surface className="rounded-lg p-4">
|
|
46
|
+
<div className="flex items-center justify-between">
|
|
47
|
+
<h3 className="font-semibold">{METRIC_TITLES[type]}</h3>
|
|
48
|
+
<span className="text-xs text-kumo-subtle">{type === "event" ? "Events" : "Visitors"}</span>
|
|
49
|
+
</div>
|
|
50
|
+
{rows.length === 0 ? (
|
|
51
|
+
<p className="text-sm text-kumo-subtle" style={{ marginTop: 12 }}>
|
|
52
|
+
{type === "event" ? "No custom events in this period." : "Nothing recorded in this period."}
|
|
53
|
+
</p>
|
|
54
|
+
) : (
|
|
55
|
+
<ol style={{ listStyle: "none", margin: "12px 0 0", padding: 0, display: "grid", gap: 4 }}>
|
|
56
|
+
{rows.map((row) => (
|
|
57
|
+
<li key={row.label} style={{ position: "relative", padding: "4px 8px", borderRadius: 6, overflow: "hidden" }}>
|
|
58
|
+
<div
|
|
59
|
+
aria-hidden
|
|
60
|
+
style={{
|
|
61
|
+
position: "absolute",
|
|
62
|
+
inset: 0,
|
|
63
|
+
width: `${max > 0 ? (row.value / max) * 100 : 0}%`,
|
|
64
|
+
background: "var(--color-kumo-tint)",
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
<div className="flex items-center justify-between gap-3 text-sm" style={{ position: "relative" }}>
|
|
68
|
+
<span className="truncate" title={row.label}>
|
|
69
|
+
{metricLabel(type, row.label)}
|
|
70
|
+
</span>
|
|
71
|
+
<span className="tabular-nums">{row.value.toLocaleString()}</span>
|
|
72
|
+
</div>
|
|
73
|
+
</li>
|
|
74
|
+
))}
|
|
75
|
+
</ol>
|
|
76
|
+
)}
|
|
77
|
+
</Surface>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function Body({ overview }: { overview: Overview }) {
|
|
82
|
+
const dark = useDarkMode();
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))" }}>
|
|
86
|
+
{kpis(overview.totals, overview.previous).map((kpi) => (
|
|
87
|
+
<Tile key={kpi.key} label={kpi.label} value={kpi.format(kpi.value)}>
|
|
88
|
+
<Delta kpi={kpi} />
|
|
89
|
+
</Tile>
|
|
90
|
+
))}
|
|
91
|
+
<ActiveTile />
|
|
92
|
+
</div>
|
|
93
|
+
<Surface className="rounded-lg p-4">
|
|
94
|
+
<React.Suspense fallback={<div style={{ height: 260 }} />}>
|
|
95
|
+
<Chart overview={overview} dark={dark} />
|
|
96
|
+
</React.Suspense>
|
|
97
|
+
</Surface>
|
|
98
|
+
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))" }}>
|
|
99
|
+
{METRIC_TYPES.map((type) => (
|
|
100
|
+
<TopList key={type} type={type} rows={overview.top[type]} />
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function OverviewView({ onOpenSettings }: { onOpenSettings: () => void }) {
|
|
108
|
+
const [range, setRange] = useRange();
|
|
109
|
+
const overview = useOverview(range);
|
|
110
|
+
const error = overview.error as (Error & { code?: string }) | null;
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="space-y-4">
|
|
114
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
115
|
+
<Tabs
|
|
116
|
+
variant="segmented"
|
|
117
|
+
size="sm"
|
|
118
|
+
value={range}
|
|
119
|
+
onValueChange={(value) => setRange(value as Range)}
|
|
120
|
+
tabs={RANGES.map((r) => ({ value: r, label: RANGE_LABELS[r] }))}
|
|
121
|
+
/>
|
|
122
|
+
<div className="flex items-center gap-3 text-xs text-kumo-subtle">
|
|
123
|
+
{overview.data?.stale ? <Badge variant="orange">Umami unreachable, showing saved data</Badge> : null}
|
|
124
|
+
{overview.data ? <span>Updated {relativeTime(overview.data.fetchedAt)}</span> : null}
|
|
125
|
+
{overview.isFetching ? <Loader size="sm" /> : null}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
{error && !overview.data ? (
|
|
129
|
+
<Banner
|
|
130
|
+
variant="error"
|
|
131
|
+
title="Could not load statistics"
|
|
132
|
+
description={
|
|
133
|
+
<>
|
|
134
|
+
{error.message}{" "}
|
|
135
|
+
{error.code === "not-configured" || error.code === "unauthorized" || error.code === "not-found" ? (
|
|
136
|
+
<Button type="button" size="sm" variant="secondary" onClick={onOpenSettings}>
|
|
137
|
+
Open settings
|
|
138
|
+
</Button>
|
|
139
|
+
) : null}
|
|
140
|
+
</>
|
|
141
|
+
}
|
|
142
|
+
/>
|
|
143
|
+
) : overview.data ? (
|
|
144
|
+
<Body overview={overview.data} />
|
|
145
|
+
) : (
|
|
146
|
+
<Loader />
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { Banner, Button, Input, Loader, Radio, Select, SensitiveInput, Surface, Switch } from "@cloudflare/kumo";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import type { ConnectionTestResult, SaveSettingsInput, SettingsView as Settings } from "../shared/api-types.js";
|
|
6
|
+
import { CLOUD_ENABLED, type AuthMode, type CloudRegion, type Hosting } from "../shared/constants.js";
|
|
7
|
+
import { defaultScriptUrl } from "../shared/tracker.js";
|
|
8
|
+
import { callRoute } from "./api.js";
|
|
9
|
+
|
|
10
|
+
interface Draft {
|
|
11
|
+
hosting: Hosting;
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
region: CloudRegion;
|
|
14
|
+
authMode: AuthMode;
|
|
15
|
+
apiKey: string;
|
|
16
|
+
username: string;
|
|
17
|
+
password: string;
|
|
18
|
+
websiteId: string;
|
|
19
|
+
injectScript: boolean;
|
|
20
|
+
clientRouter: boolean;
|
|
21
|
+
scriptUrl: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function draftFrom(s: Settings): Draft {
|
|
25
|
+
const v = s.values;
|
|
26
|
+
return {
|
|
27
|
+
hosting: v.hosting,
|
|
28
|
+
baseUrl: v.baseUrl ?? "",
|
|
29
|
+
region: v.region,
|
|
30
|
+
authMode: v.authMode,
|
|
31
|
+
apiKey: "",
|
|
32
|
+
username: v.username ?? "",
|
|
33
|
+
password: "",
|
|
34
|
+
websiteId: v.websiteId ?? "",
|
|
35
|
+
injectScript: v.injectScript,
|
|
36
|
+
clientRouter: v.clientRouter,
|
|
37
|
+
scriptUrl: v.scriptUrl ?? "",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Only send what the chosen setup uses; empty secret fields mean "keep the saved one". */
|
|
42
|
+
function toInput(d: Draft): SaveSettingsInput {
|
|
43
|
+
const input: SaveSettingsInput = {
|
|
44
|
+
hosting: d.hosting,
|
|
45
|
+
authMode: d.hosting === "cloud" ? "apiKey" : d.authMode,
|
|
46
|
+
websiteId: d.websiteId || null,
|
|
47
|
+
injectScript: d.injectScript,
|
|
48
|
+
clientRouter: d.clientRouter,
|
|
49
|
+
scriptUrl: d.scriptUrl || null,
|
|
50
|
+
};
|
|
51
|
+
if (d.hosting === "self-hosted") input.baseUrl = d.baseUrl || null;
|
|
52
|
+
else input.region = d.region;
|
|
53
|
+
if (d.apiKey) input.apiKey = d.apiKey;
|
|
54
|
+
if (input.authMode === "password") {
|
|
55
|
+
input.username = d.username || null;
|
|
56
|
+
if (d.password) input.password = d.password;
|
|
57
|
+
}
|
|
58
|
+
return input;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Section({ title, description, children }: { title: string; description?: React.ReactNode; children: React.ReactNode }) {
|
|
62
|
+
return (
|
|
63
|
+
<Surface className="rounded-lg p-5">
|
|
64
|
+
<div className="space-y-4">
|
|
65
|
+
<div className="space-y-1">
|
|
66
|
+
<h2 className="text-lg font-semibold">{title}</h2>
|
|
67
|
+
{description ? <p className="text-sm text-kumo-subtle">{description}</p> : null}
|
|
68
|
+
</div>
|
|
69
|
+
{children}
|
|
70
|
+
</div>
|
|
71
|
+
</Surface>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function urlError(value: string): string | undefined {
|
|
76
|
+
if (!value) return undefined;
|
|
77
|
+
try {
|
|
78
|
+
const url = new URL(value);
|
|
79
|
+
return url.protocol === "https:" || url.protocol === "http:" ? undefined : "Use an address starting with https://";
|
|
80
|
+
} catch {
|
|
81
|
+
return "Use a full address, for example https://stats.example.com";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function trackerUrl(d: Draft): string | null {
|
|
86
|
+
return defaultScriptUrl(d.hosting, d.baseUrl || null, d.scriptUrl || null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function SettingsView({ onSaved }: { onSaved?: () => void }) {
|
|
90
|
+
const queryClient = useQueryClient();
|
|
91
|
+
const settings = useQuery({ queryKey: ["plugin-umami", "settings"], queryFn: () => callRoute<Settings>("settings") });
|
|
92
|
+
const [draft, setDraft] = React.useState<Draft | null>(null);
|
|
93
|
+
const [showCustomScript, setShowCustomScript] = React.useState(false);
|
|
94
|
+
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
if (settings.data && !draft) {
|
|
97
|
+
setDraft(draftFrom(settings.data));
|
|
98
|
+
setShowCustomScript(Boolean(settings.data.values.scriptUrl));
|
|
99
|
+
}
|
|
100
|
+
}, [settings.data, draft]);
|
|
101
|
+
|
|
102
|
+
const hasCredentials = settings.data
|
|
103
|
+
? settings.data.values.authMode === "apiKey"
|
|
104
|
+
? settings.data.secretsSet.apiKey
|
|
105
|
+
: settings.data.secretsSet.password && Boolean(settings.data.values.username)
|
|
106
|
+
: false;
|
|
107
|
+
|
|
108
|
+
// Check the saved connection on load so the website list is ready.
|
|
109
|
+
const test = useQuery({
|
|
110
|
+
queryKey: ["plugin-umami", "connection"],
|
|
111
|
+
queryFn: () => callRoute<ConnectionTestResult>("connection/test", {}),
|
|
112
|
+
enabled: hasCredentials,
|
|
113
|
+
staleTime: 5 * 60_000,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const save = useMutation({
|
|
117
|
+
mutationFn: async (d: Draft) => {
|
|
118
|
+
const saved = await callRoute<Settings>("settings/save", toInput(d));
|
|
119
|
+
const result = await callRoute<ConnectionTestResult>("connection/test", {});
|
|
120
|
+
return { saved, result };
|
|
121
|
+
},
|
|
122
|
+
onSuccess: ({ saved, result }) => {
|
|
123
|
+
queryClient.setQueryData(["plugin-umami", "settings"], saved);
|
|
124
|
+
queryClient.setQueryData(["plugin-umami", "connection"], result);
|
|
125
|
+
void queryClient.invalidateQueries({ queryKey: ["plugin-umami", "status"] });
|
|
126
|
+
setDraft(draftFrom(saved));
|
|
127
|
+
onSaved?.();
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (settings.isLoading || !draft) {
|
|
132
|
+
return settings.error ? (
|
|
133
|
+
<Banner variant="error" title="Could not load the settings" description={String((settings.error as Error).message)} />
|
|
134
|
+
) : (
|
|
135
|
+
<Loader />
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setDraft((d) => (d ? { ...d, [key]: value } : d));
|
|
140
|
+
const secrets = settings.data?.secretsSet ?? { apiKey: false, password: false };
|
|
141
|
+
const connection = test.data;
|
|
142
|
+
const websites = connection?.ok ? connection.websites : [];
|
|
143
|
+
const selectedKnown = websites.some((w) => w.id === draft.websiteId);
|
|
144
|
+
const tracker = trackerUrl(draft);
|
|
145
|
+
const authMode: AuthMode = draft.hosting === "cloud" ? "apiKey" : draft.authMode;
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<form
|
|
149
|
+
className="max-w-2xl space-y-6"
|
|
150
|
+
onSubmit={(event) => {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
if (draft.hosting === "self-hosted" && urlError(draft.baseUrl)) return;
|
|
153
|
+
if (draft.scriptUrl && urlError(draft.scriptUrl)) return;
|
|
154
|
+
save.mutate(draft);
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<Section title="Umami server" description="Where your Umami statistics live.">
|
|
158
|
+
<Radio.Group
|
|
159
|
+
legend="Hosting"
|
|
160
|
+
orientation="horizontal"
|
|
161
|
+
value={draft.hosting}
|
|
162
|
+
onValueChange={(value) => set("hosting", value as Hosting)}
|
|
163
|
+
>
|
|
164
|
+
<Radio.Item label="Self-hosted Umami" value="self-hosted" />
|
|
165
|
+
<Radio.Item
|
|
166
|
+
label={CLOUD_ENABLED ? "Umami Cloud" : "Umami Cloud (coming in 0.2.0)"}
|
|
167
|
+
value="cloud"
|
|
168
|
+
disabled={!CLOUD_ENABLED}
|
|
169
|
+
/>
|
|
170
|
+
</Radio.Group>
|
|
171
|
+
{draft.hosting === "self-hosted" ? (
|
|
172
|
+
<Input
|
|
173
|
+
label="Umami URL"
|
|
174
|
+
description="The address you open Umami at, for example https://stats.example.com."
|
|
175
|
+
type="url"
|
|
176
|
+
placeholder="https://stats.example.com"
|
|
177
|
+
value={draft.baseUrl}
|
|
178
|
+
error={urlError(draft.baseUrl)}
|
|
179
|
+
onChange={(event) => set("baseUrl", event.target.value.trim())}
|
|
180
|
+
/>
|
|
181
|
+
) : (
|
|
182
|
+
<Select
|
|
183
|
+
label="Region"
|
|
184
|
+
description="Leave on account default unless Umami support told you otherwise."
|
|
185
|
+
value={draft.region}
|
|
186
|
+
onValueChange={(value) => set("region", value as CloudRegion)}
|
|
187
|
+
items={{ default: "Account default", us: "United States", eu: "European Union" }}
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
</Section>
|
|
191
|
+
|
|
192
|
+
<Section
|
|
193
|
+
title="Sign in"
|
|
194
|
+
description={
|
|
195
|
+
authMode === "apiKey"
|
|
196
|
+
? "Create a key in Umami under Settings → API keys. It is stored encrypted and never shown again."
|
|
197
|
+
: "Use this only for Umami versions without API keys. The password is stored encrypted."
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
{draft.hosting === "self-hosted" ? (
|
|
201
|
+
<Radio.Group
|
|
202
|
+
legend="Method"
|
|
203
|
+
orientation="horizontal"
|
|
204
|
+
value={draft.authMode}
|
|
205
|
+
onValueChange={(value) => set("authMode", value as AuthMode)}
|
|
206
|
+
>
|
|
207
|
+
<Radio.Item label="API key" value="apiKey" />
|
|
208
|
+
<Radio.Item label="Username and password" value="password" />
|
|
209
|
+
</Radio.Group>
|
|
210
|
+
) : null}
|
|
211
|
+
{authMode === "apiKey" ? (
|
|
212
|
+
<SensitiveInput
|
|
213
|
+
label="API key"
|
|
214
|
+
description={secrets.apiKey ? "A key is saved. Paste a new one only to replace it." : undefined}
|
|
215
|
+
placeholder={secrets.apiKey ? "Saved key, leave empty to keep" : "umami_…"}
|
|
216
|
+
value={draft.apiKey}
|
|
217
|
+
onValueChange={(value) => set("apiKey", value)}
|
|
218
|
+
/>
|
|
219
|
+
) : (
|
|
220
|
+
<>
|
|
221
|
+
<Input label="Username" value={draft.username} onChange={(event) => set("username", event.target.value)} />
|
|
222
|
+
<SensitiveInput
|
|
223
|
+
label="Password"
|
|
224
|
+
description={secrets.password ? "A password is saved. Type a new one only to replace it." : undefined}
|
|
225
|
+
placeholder={secrets.password ? "Saved password, leave empty to keep" : ""}
|
|
226
|
+
value={draft.password}
|
|
227
|
+
onValueChange={(value) => set("password", value)}
|
|
228
|
+
/>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
</Section>
|
|
232
|
+
|
|
233
|
+
<Section title="Website" description="Which Umami website belongs to this site.">
|
|
234
|
+
{test.isFetching ? (
|
|
235
|
+
<Loader />
|
|
236
|
+
) : websites.length > 0 ? (
|
|
237
|
+
<Select
|
|
238
|
+
label="Website"
|
|
239
|
+
placeholder="Choose a website"
|
|
240
|
+
value={draft.websiteId || null}
|
|
241
|
+
onValueChange={(value) => set("websiteId", typeof value === "string" ? value : "")}
|
|
242
|
+
items={Object.fromEntries([
|
|
243
|
+
...websites.map((w) => [w.id, w.domain ? `${w.name} (${w.domain})` : w.name]),
|
|
244
|
+
...(draft.websiteId && !selectedKnown ? [[draft.websiteId, `${draft.websiteId} (not found in Umami)`]] : []),
|
|
245
|
+
])}
|
|
246
|
+
/>
|
|
247
|
+
) : hasCredentials && connection?.ok ? (
|
|
248
|
+
<Input
|
|
249
|
+
label="Website ID"
|
|
250
|
+
description="This Umami account lists no websites, so enter the ID by hand. In Umami, open Settings → Websites → Edit and copy the Website ID."
|
|
251
|
+
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
252
|
+
value={draft.websiteId}
|
|
253
|
+
onChange={(event) => set("websiteId", event.target.value.trim())}
|
|
254
|
+
/>
|
|
255
|
+
) : (
|
|
256
|
+
<p className="text-sm text-kumo-subtle">
|
|
257
|
+
{hasCredentials
|
|
258
|
+
? "No websites to choose from until the connection works."
|
|
259
|
+
: "Save your server and sign-in details first. The websites you can see in Umami will then appear here."}
|
|
260
|
+
</p>
|
|
261
|
+
)}
|
|
262
|
+
</Section>
|
|
263
|
+
|
|
264
|
+
<Section title="Tracking" description="Optional. Let this plugin add the Umami tracker to your public pages.">
|
|
265
|
+
<Switch
|
|
266
|
+
label="Add the Umami tracker to public pages"
|
|
267
|
+
checked={draft.injectScript}
|
|
268
|
+
onCheckedChange={(checked) => set("injectScript", checked)}
|
|
269
|
+
/>
|
|
270
|
+
<p className="text-sm text-kumo-subtle">
|
|
271
|
+
If your site caches pages, cached copies change over when their cache expires, not the moment you save.
|
|
272
|
+
</p>
|
|
273
|
+
{draft.injectScript ? (
|
|
274
|
+
<>
|
|
275
|
+
<Banner
|
|
276
|
+
variant="alert"
|
|
277
|
+
title="Remove any tracker already in your theme"
|
|
278
|
+
description="If the theme also includes the Umami script, every visit is counted twice. The theme must render EmDashHead."
|
|
279
|
+
/>
|
|
280
|
+
<Switch
|
|
281
|
+
label="The site uses Astro's <ClientRouter /> (view transitions)"
|
|
282
|
+
checked={draft.clientRouter}
|
|
283
|
+
onCheckedChange={(checked) => set("clientRouter", checked)}
|
|
284
|
+
/>
|
|
285
|
+
<p className="text-sm text-kumo-subtle">
|
|
286
|
+
Turn this on if your layout includes <code><ClientRouter /></code>. Pages then change without a full
|
|
287
|
+
load, and this counts each one once, including back and forward. Needs Umami 3.2 or later.
|
|
288
|
+
</p>
|
|
289
|
+
</>
|
|
290
|
+
) : null}
|
|
291
|
+
<p className="text-sm text-kumo-subtle">
|
|
292
|
+
Tracker script: <code>{tracker ?? "set the Umami URL first"}</code>{" "}
|
|
293
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => setShowCustomScript((v) => !v)}>
|
|
294
|
+
{showCustomScript ? "Use the default" : "Use a custom script URL"}
|
|
295
|
+
</Button>
|
|
296
|
+
</p>
|
|
297
|
+
{showCustomScript ? (
|
|
298
|
+
<Input
|
|
299
|
+
label="Custom tracker script URL"
|
|
300
|
+
description="For a renamed tracker (recommended against ad blockers) or a proxy."
|
|
301
|
+
type="url"
|
|
302
|
+
value={draft.scriptUrl}
|
|
303
|
+
error={urlError(draft.scriptUrl)}
|
|
304
|
+
onChange={(event) => set("scriptUrl", event.target.value.trim())}
|
|
305
|
+
/>
|
|
306
|
+
) : null}
|
|
307
|
+
</Section>
|
|
308
|
+
|
|
309
|
+
<div className="space-y-4">
|
|
310
|
+
{save.error ? (
|
|
311
|
+
<Banner variant="error" title="Could not save" description={(save.error as Error).message} />
|
|
312
|
+
) : null}
|
|
313
|
+
{connection && !test.isFetching ? (
|
|
314
|
+
connection.ok ? (
|
|
315
|
+
<Banner
|
|
316
|
+
title="Connected to Umami"
|
|
317
|
+
description={
|
|
318
|
+
connection.website
|
|
319
|
+
? `Showing statistics for ${connection.website.name}.`
|
|
320
|
+
: `${connection.websites.length} website${connection.websites.length === 1 ? "" : "s"} available. Choose one above and save.`
|
|
321
|
+
}
|
|
322
|
+
/>
|
|
323
|
+
) : (
|
|
324
|
+
<Banner variant="error" title="Could not connect to Umami" description={connection.message} />
|
|
325
|
+
)
|
|
326
|
+
) : null}
|
|
327
|
+
<Button type="submit" variant="primary" loading={save.isPending}>
|
|
328
|
+
Save and test connection
|
|
329
|
+
</Button>
|
|
330
|
+
</div>
|
|
331
|
+
</form>
|
|
332
|
+
);
|
|
333
|
+
}
|