@raketa-cloud/admin 1.8.18 → 1.8.19
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/application.css +288 -11
- package/package.json +8 -6
- package/src/handlers/resources/ReportsAPIHandler.js +27 -0
- package/src/handlers/resources/ResourcesHandler.js +16 -0
- package/src/helpers/reports/cwv.js +217 -0
- package/src/helpers/reports/googleAuth.js +149 -0
- package/src/helpers/reports/quick-insights.js +294 -0
- package/src/helpers/reports/reportsUtils.js +181 -0
- package/src/index.js +8 -0
- package/src/localCache.js +112 -0
- package/src/report/analytics.js +107 -0
- package/src/report/reports.js +390 -0
- package/src/routeAssets.js +74 -0
- package/src/routeResourceModule.js +24 -0
- package/src/routeStandard.js +7 -1
- package/src/styles/application.css +1 -1
- package/src/tests/handlers/RedirectsHandler.test/notes.test.js +56 -0
- package/src/tests/handlers/_shared/SettingsHandler.test/global_variables.test.js +54 -0
- package/src/tests/handlers/categories/update_defaults.test.js +35 -0
- package/src/tests/handlers/content_tasks/ContentTasksHandler.test/editors.test.js +29 -0
- package/src/tests/handlers/resource_revisions/PublishesHandler.test/schedule.test.js +137 -0
- package/src/tests/handlers/resource_revisions/TagHandler.test/default_locale_tags.test.js +162 -0
- package/src/tests/handlers/resource_revisions/WidgetHandler.test/defaults.test.js +61 -0
- package/src/tests/handlers/resources/DeletesHandler.test/no_redirect.test.js +55 -0
- package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +156 -0
- package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +8 -0
- package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +94 -0
- package/src/tests/handlers/resources/TranslationsHandler.test/edge_cases.test.js +211 -0
- package/src/tests/helpers/CSVExportHelper.branches.test.js +57 -0
- package/src/tests/helpers/FormBuilder.branches.test.js +151 -0
- package/src/tests/helpers/HTMLBuilder.test.js +51 -0
- package/src/tests/helpers/historyUtils.branches.test.js +36 -0
- package/src/tests/helpers/reports/cwv.test.js +239 -0
- package/src/tests/helpers/reports/quick-insights.test.js +593 -0
- package/src/tests/helpers/reports/reportsUtils.test.js +425 -0
- package/src/tests/helpers/utils.test.js +37 -0
- package/src/tests/helpers/view_helpers.test.js +87 -0
- package/src/tests/index.test.js +74 -0
- package/src/tests/localCache.test.js +236 -0
- package/src/tests/report/analytics.test.js +230 -0
- package/src/tests/report/reports.test.js +831 -0
- package/src/tests/routeAssets.test.js +120 -0
- package/src/tests/routeResourceModule.test.js +99 -2
- package/src/tests/routeStandard.test.js +3 -3
- package/src/tests/support/fakeDom.js +164 -0
- package/src/views/resources/_shared/_core_web_vitals.ejs +27 -0
- package/src/views/resources/_shared/_quick_insights.ejs +64 -0
- package/src/views/resources/_shared/_scheduled_revisions.ejs +88 -0
- package/src/views/resources/_shared/_title.ejs +16 -12
- package/src/views/resources/_shared/_toolbar.ejs +11 -21
- package/src/views/resources/_shared/_traffic_chart.ejs +27 -0
- package/src/views/resources/_shared/_translations_strip.ejs +13 -25
- package/src/views/resources/_shared/_vital_meter.ejs +54 -0
- package/src/views/resources/show.ejs +88 -98
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { cruxApiKeyConfigured, pageWebVitals } from "./cwv";
|
|
2
|
+
import { serviceAccountConfigured } from "./googleAuth";
|
|
3
|
+
import { pageInsights } from "./quick-insights";
|
|
4
|
+
|
|
5
|
+
const reportsEnabled = (ctx) => Boolean(ctx.resourceConfig?.reports);
|
|
6
|
+
|
|
7
|
+
// The two APIs take opposite credentials: GA is per-property authorized and
|
|
8
|
+
// rejects API keys (service account), CrUX is a public dataset and rejects
|
|
9
|
+
// OAuth (API key). Each tab is gated on its own.
|
|
10
|
+
const gaEnabled = (ctx) =>
|
|
11
|
+
reportsEnabled(ctx) &&
|
|
12
|
+
Boolean(ctx.globalConfig?.ga?.propertyId) &&
|
|
13
|
+
serviceAccountConfigured(ctx.globalConfig?.ga);
|
|
14
|
+
|
|
15
|
+
const cwvEnabled = (ctx) =>
|
|
16
|
+
reportsEnabled(ctx) && cruxApiKeyConfigured(ctx.globalConfig?.ga);
|
|
17
|
+
|
|
18
|
+
const chartLabel = (iso) =>
|
|
19
|
+
new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-US", {
|
|
20
|
+
month: "long",
|
|
21
|
+
day: "numeric",
|
|
22
|
+
timeZone: "UTC",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// insertedAt is an ISO timestamp; take its day as written rather than through
|
|
26
|
+
// Date, which would shift a timezone-less string into local time.
|
|
27
|
+
const isoDay = (value) => {
|
|
28
|
+
if (!value) return null;
|
|
29
|
+
|
|
30
|
+
const day = String(value).match(/^\d{4}-\d{2}-\d{2}/);
|
|
31
|
+
if (day) return day[0];
|
|
32
|
+
|
|
33
|
+
const date = new Date(value);
|
|
34
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// GA knows nothing about revisions, so the publish markers come from the
|
|
38
|
+
// resource's own change log and are joined onto the series by day.
|
|
39
|
+
async function resourcePublishDates(ctx, resource, resourceType) {
|
|
40
|
+
try {
|
|
41
|
+
const changes = await ctx.client.resourceChanges.list(
|
|
42
|
+
{ "f:change_type": ["revision_publish"] },
|
|
43
|
+
resource.id,
|
|
44
|
+
ctx.locale,
|
|
45
|
+
resourceType,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return (changes?.list ?? []).map((change) => isoDay(change.insertedAt));
|
|
49
|
+
} catch {
|
|
50
|
+
// A missing history shouldn't cost the whole report its chart.
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildChartSeries(rows, publishes = []) {
|
|
56
|
+
const dates = rows.map((r) => r.date);
|
|
57
|
+
const onAxis = new Set(dates);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
series: {
|
|
61
|
+
dates,
|
|
62
|
+
labels: dates.map(chartLabel),
|
|
63
|
+
data: rows.map((r) => r.views),
|
|
64
|
+
},
|
|
65
|
+
// Only days the chart actually plots can carry a marker; older publishes
|
|
66
|
+
// fall outside the 30-day window.
|
|
67
|
+
publishDates: [...new Set(publishes)]
|
|
68
|
+
.filter((day) => day && onAxis.has(day))
|
|
69
|
+
.sort(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Sites rewrite the homepage resource to the site root, so its analytics live
|
|
74
|
+
// under "/" and never under "/<slug>". Override the slug per project with
|
|
75
|
+
// config.ga.homepageSlug.
|
|
76
|
+
const DEFAULT_HOMEPAGE_SLUG = "homepage";
|
|
77
|
+
|
|
78
|
+
const isHomepage = (ctx, resource) =>
|
|
79
|
+
resource.slug ===
|
|
80
|
+
(ctx.globalConfig?.ga?.homepageSlug ?? DEFAULT_HOMEPAGE_SLUG);
|
|
81
|
+
|
|
82
|
+
function gaPagePath(ctx, resource, resourceType) {
|
|
83
|
+
if (isHomepage(ctx, resource)) return "/";
|
|
84
|
+
return ctx.remoteConfig.resourcePath(resource, resourceType, ctx.locale);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cwvPageUrl(ctx, resource, resourceType) {
|
|
88
|
+
const base = ctx.globalConfig?.ga?.publicUrl;
|
|
89
|
+
if (base) {
|
|
90
|
+
return new URL(gaPagePath(ctx, resource, resourceType), base).toString();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pageUrl = ctx.remoteConfig.resourceUrl(
|
|
94
|
+
resource,
|
|
95
|
+
resourceType,
|
|
96
|
+
ctx.locale,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Without a configured publicUrl the resource URL still carries "/<slug>" —
|
|
100
|
+
// collapse it to the origin root so CrUX is asked about the real page.
|
|
101
|
+
if (isHomepage(ctx, resource)) {
|
|
102
|
+
const url = new URL(pageUrl);
|
|
103
|
+
url.pathname = "/";
|
|
104
|
+
url.search = "";
|
|
105
|
+
url.hash = "";
|
|
106
|
+
return url.toString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return pageUrl;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Cache entries are per resource, not per URL: paths collide (the homepage
|
|
113
|
+
// rewrite, locale rewrites), and a shared entry meant one resource's report
|
|
114
|
+
// stood in for another's. Resource ids are unique per locale already.
|
|
115
|
+
const reportCacheKey = (resource, resourceType) =>
|
|
116
|
+
`${resourceType}:${resource.id}`;
|
|
117
|
+
|
|
118
|
+
async function fetchWebVitals(ctx, resource, resourceType) {
|
|
119
|
+
try {
|
|
120
|
+
const pageUrl = cwvPageUrl(ctx, resource, resourceType);
|
|
121
|
+
|
|
122
|
+
const data = await pageWebVitals(pageUrl, ctx.globalConfig?.ga, {
|
|
123
|
+
cacheKey: reportCacheKey(resource, resourceType),
|
|
124
|
+
});
|
|
125
|
+
if (!data) {
|
|
126
|
+
throw new Error("No Core Web Vitals data (check server logs)");
|
|
127
|
+
}
|
|
128
|
+
return { pageUrl, ...data, error: null };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return { error: err.message || String(err) };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fetchQuickInsights(ctx, resource, resourceType) {
|
|
135
|
+
try {
|
|
136
|
+
if (!gaEnabled(ctx)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"Google Analytics is not configured. Set config.ga.propertyId and config.ga.serviceAccountPath (GOOGLE_APPLICATION_CREDENTIALS) to enable reports.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const pagePath = gaPagePath(ctx, resource, resourceType);
|
|
142
|
+
|
|
143
|
+
const [reports, publishes] = await Promise.all([
|
|
144
|
+
pageInsights(pagePath, ctx.globalConfig?.ga, {
|
|
145
|
+
cacheKey: reportCacheKey(resource, resourceType),
|
|
146
|
+
}),
|
|
147
|
+
resourcePublishDates(ctx, resource, resourceType),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
if (!reports) {
|
|
151
|
+
throw new Error("No analytics data (check GA config / server logs)");
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
pagePath,
|
|
155
|
+
summary: reports.summary,
|
|
156
|
+
dateRange: reports.dateRange,
|
|
157
|
+
...buildChartSeries(reports.rows ?? [], publishes),
|
|
158
|
+
error: null,
|
|
159
|
+
};
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return { error: err.message || String(err) };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Which reports this resource can show, without fetching anything. The Settings
|
|
166
|
+
// page uses it to decide which skeletons to render before the client fetches.
|
|
167
|
+
export const reportsAvailability = (ctx) => ({
|
|
168
|
+
insights: gaEnabled(ctx),
|
|
169
|
+
webVitals: cwvEnabled(ctx),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Both reports for the Settings page, fetched together. Either side is null
|
|
173
|
+
// when its credential isn't configured, so the section simply doesn't render.
|
|
174
|
+
export async function resourceReports(ctx, resource, resourceType) {
|
|
175
|
+
const [insights, webVitals] = await Promise.all([
|
|
176
|
+
gaEnabled(ctx) ? fetchQuickInsights(ctx, resource, resourceType) : null,
|
|
177
|
+
cwvEnabled(ctx) ? fetchWebVitals(ctx, resource, resourceType) : null,
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
return { insights, webVitals };
|
|
181
|
+
}
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,14 @@ export { default as ResourceRevisions } from "./cloud/ResourceRevisions";
|
|
|
22
22
|
export { default as Resources } from "./cloud/Resources";
|
|
23
23
|
export { default as Categories } from "./cloud/Categories";
|
|
24
24
|
|
|
25
|
+
export { default as localCache } from "./localCache";
|
|
26
|
+
export {
|
|
27
|
+
pageInsights,
|
|
28
|
+
runPageReport,
|
|
29
|
+
runSummary,
|
|
30
|
+
} from "./helpers/reports/quick-insights";
|
|
31
|
+
export { pageWebVitals } from "./helpers/reports/cwv";
|
|
32
|
+
|
|
25
33
|
export { default as ContentTasksHandler } from "./handlers/content_tasks/ContentTasksHandler";
|
|
26
34
|
export { default as RedirectsHandler } from "./handlers/RedirectsHandler";
|
|
27
35
|
export { default as TagsHandler } from "./handlers/TagsHandler";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { LRUCache } from "lru-cache";
|
|
2
|
+
|
|
3
|
+
const UNDEFINED_VALUE = Symbol.for("localCache.udef");
|
|
4
|
+
|
|
5
|
+
const cacheOptions = {
|
|
6
|
+
max: 8000,
|
|
7
|
+
maxSize: 300 * 1024 * 1024,
|
|
8
|
+
// maxSize is in bytes, so entries are measured by their serialized length.
|
|
9
|
+
// Nothing that can't be measured may take the caller's request down with it:
|
|
10
|
+
// JSON.stringify answers undefined for undefined / functions / symbols and
|
|
11
|
+
// throws outright on a circular structure or a BigInt.
|
|
12
|
+
sizeCalculation: (value) => {
|
|
13
|
+
if (value === UNDEFINED_VALUE) return 1;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const json = JSON.stringify(value);
|
|
17
|
+
return json === undefined ? 1 : Buffer.byteLength(json);
|
|
18
|
+
} catch {
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
ttl: 1000 * 60 * 3, // 3 minutes
|
|
23
|
+
// When we get a stale result and can't fetch a fresh one - use the stale one more time..
|
|
24
|
+
allowStale: true,
|
|
25
|
+
noDeleteOnStaleGet: true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Use the globalThis in order to prevent multiple instances of the cache..
|
|
29
|
+
let cacheInstance = null;
|
|
30
|
+
|
|
31
|
+
if (globalThis.cacheInstance) {
|
|
32
|
+
cacheInstance = globalThis.cacheInstance;
|
|
33
|
+
} else {
|
|
34
|
+
cacheInstance = new LRUCache({
|
|
35
|
+
...cacheOptions,
|
|
36
|
+
});
|
|
37
|
+
globalThis.cacheInstance = cacheInstance;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fetchFromCache(key) {
|
|
41
|
+
const status = {};
|
|
42
|
+
|
|
43
|
+
// storedValue is what the cache holds, sentinel and all — a stale entry is
|
|
44
|
+
// written back as it was found, so a failed refresh can't quietly turn a
|
|
45
|
+
// cached "nothing" into a cached null.
|
|
46
|
+
const storedValue = cacheInstance.get(key, { status });
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
cachedValue: storedValue === UNDEFINED_VALUE ? null : storedValue,
|
|
50
|
+
storedValue,
|
|
51
|
+
status,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// lru-cache treats set(key, undefined) as a delete, so the sentinel stands in
|
|
56
|
+
// for it. Only undefined maps to it — 0, "", false and null are real values and
|
|
57
|
+
// must survive a round trip through the cache.
|
|
58
|
+
const toCacheValue = (value) => (value === undefined ? UNDEFINED_VALUE : value);
|
|
59
|
+
|
|
60
|
+
function setInCache(key, value, ttl) {
|
|
61
|
+
cacheInstance.set(key, toCacheValue(value), ttl ? { ttl } : undefined);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clearCache() {
|
|
65
|
+
cacheInstance.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fetchWithCache(cacheKey, fetcher, { ttl } = {}) {
|
|
69
|
+
const { cachedValue, storedValue, status } = fetchFromCache(cacheKey);
|
|
70
|
+
|
|
71
|
+
if (status.get === "hit") {
|
|
72
|
+
return cachedValue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (status.get === "stale") {
|
|
76
|
+
try {
|
|
77
|
+
const freshEntity = await fetcher();
|
|
78
|
+
|
|
79
|
+
setInCache(cacheKey, freshEntity, ttl);
|
|
80
|
+
|
|
81
|
+
return freshEntity ?? null;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.error(e);
|
|
84
|
+
|
|
85
|
+
// Reset TTL — with the value exactly as it was stored.
|
|
86
|
+
setInCache(cacheKey, storedValue, ttl);
|
|
87
|
+
|
|
88
|
+
return cachedValue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const freshEntity = await fetcher();
|
|
94
|
+
|
|
95
|
+
setInCache(cacheKey, freshEntity, ttl);
|
|
96
|
+
|
|
97
|
+
return freshEntity ?? null;
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.error(e);
|
|
100
|
+
|
|
101
|
+
// On a cache miss there is no stale value to preserve, so leave the cache
|
|
102
|
+
// untouched and let the next caller retry immediately.
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default {
|
|
108
|
+
fetch: fetchWithCache,
|
|
109
|
+
clear: clearCache,
|
|
110
|
+
forEach: (callback) => cacheInstance.forEach(callback),
|
|
111
|
+
getRemainingTTL: (key) => cacheInstance.getRemainingTTL(key),
|
|
112
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Traffic-trends chart. Two entry points:
|
|
2
|
+
// - a page that inlines its data as window.GA_ANALYTICS, drawn on load
|
|
3
|
+
// - the Settings page fetches its data and calls window.gaRenderTrafficChart()
|
|
4
|
+
// (see reports.js)
|
|
5
|
+
//
|
|
6
|
+
// Shape either way:
|
|
7
|
+
// { series: { dates: ["YYYY-MM-DD", ...], labels: ["July 5", ...], data: [] },
|
|
8
|
+
// publishDates: ["YYYY-MM-DD", ...] }
|
|
9
|
+
(() => {
|
|
10
|
+
let gaChart = null;
|
|
11
|
+
|
|
12
|
+
function gaRenderTrafficChart(cfg) {
|
|
13
|
+
let el = document.getElementById("ga-traffic-chart");
|
|
14
|
+
if (!el || typeof Chart === "undefined") return;
|
|
15
|
+
|
|
16
|
+
let series = (cfg && cfg.series) || {};
|
|
17
|
+
let dates = series.dates || [];
|
|
18
|
+
let publishDates = new Set((cfg && cfg.publishDates) || []);
|
|
19
|
+
let isPublish = (i) => publishDates.has(dates[i]);
|
|
20
|
+
|
|
21
|
+
// Re-rendering replaces the chart rather than stacking a second one. Ask
|
|
22
|
+
// Chart.js which instance owns this canvas instead of trusting the local
|
|
23
|
+
// handle: if the canvas was replaced, that handle points at a detached
|
|
24
|
+
// chart and the new one would collide with a registry entry we never
|
|
25
|
+
// cleared ("Canvas is already in use").
|
|
26
|
+
let existing =
|
|
27
|
+
(typeof Chart.getChart === "function" && Chart.getChart(el)) || gaChart;
|
|
28
|
+
if (existing) existing.destroy();
|
|
29
|
+
|
|
30
|
+
gaChart = new Chart(el.getContext("2d"), {
|
|
31
|
+
type: "line",
|
|
32
|
+
data: {
|
|
33
|
+
labels: series.labels || [],
|
|
34
|
+
datasets: [
|
|
35
|
+
{
|
|
36
|
+
label: "Page Views",
|
|
37
|
+
data: series.data || [],
|
|
38
|
+
borderColor: "#6366f1",
|
|
39
|
+
backgroundColor: (context) => {
|
|
40
|
+
let chart = context.chart;
|
|
41
|
+
let ctx = chart.ctx;
|
|
42
|
+
let chartArea = chart.chartArea;
|
|
43
|
+
if (!chartArea) return null;
|
|
44
|
+
let g = ctx.createLinearGradient(
|
|
45
|
+
0,
|
|
46
|
+
chartArea.bottom,
|
|
47
|
+
0,
|
|
48
|
+
chartArea.top,
|
|
49
|
+
);
|
|
50
|
+
g.addColorStop(0, "rgba(99, 102, 241, 0)");
|
|
51
|
+
g.addColorStop(1, "rgba(99, 102, 241, 0.15)");
|
|
52
|
+
return g;
|
|
53
|
+
},
|
|
54
|
+
fill: true,
|
|
55
|
+
tension: 0.4,
|
|
56
|
+
pointRadius: (c) => (isPublish(c.dataIndex) ? 6 : 4),
|
|
57
|
+
// Publish markers are yellow-400/500 — kept in step with the
|
|
58
|
+
// "Published revision" legend dot in _traffic_chart.ejs.
|
|
59
|
+
pointBackgroundColor: (c) =>
|
|
60
|
+
isPublish(c.dataIndex) ? "#facc15" : "#fff",
|
|
61
|
+
pointBorderColor: (c) =>
|
|
62
|
+
isPublish(c.dataIndex) ? "#eab308" : "#6366f1",
|
|
63
|
+
pointBorderWidth: 3,
|
|
64
|
+
pointHoverRadius: (c) => (isPublish(c.dataIndex) ? 10 : 8),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
options: {
|
|
69
|
+
responsive: true,
|
|
70
|
+
maintainAspectRatio: false,
|
|
71
|
+
plugins: {
|
|
72
|
+
legend: { display: false },
|
|
73
|
+
tooltip: {
|
|
74
|
+
callbacks: {
|
|
75
|
+
label: (c) => ` Views: ${c.raw}`,
|
|
76
|
+
afterLabel: (c) =>
|
|
77
|
+
isPublish(c.dataIndex) ? "Revision published" : "",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
scales: {
|
|
82
|
+
y: {
|
|
83
|
+
beginAtZero: true,
|
|
84
|
+
grid: { color: "#e2e8f0" },
|
|
85
|
+
ticks: { color: "#94a3b8", font: { size: 10 } },
|
|
86
|
+
},
|
|
87
|
+
x: {
|
|
88
|
+
grid: { display: false },
|
|
89
|
+
ticks: { color: "#94a3b8", font: { size: 10 }, maxRotation: 0 },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
window.gaRenderTrafficChart = gaRenderTrafficChart;
|
|
97
|
+
|
|
98
|
+
// Inlined data (Insights tab) draws itself; the Settings page has none and
|
|
99
|
+
// waits for reports.js instead.
|
|
100
|
+
function gaRenderInlined() {
|
|
101
|
+
if (!window.GA_ANALYTICS) return;
|
|
102
|
+
gaRenderTrafficChart(window.GA_ANALYTICS);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (document.readyState !== "loading") gaRenderInlined();
|
|
106
|
+
else document.addEventListener("DOMContentLoaded", gaRenderInlined);
|
|
107
|
+
})();
|