diff --git a/znai-core/src/main/java/org/testingisdocumenting/znai/core/DocMeta.java b/znai-core/src/main/java/org/testingisdocumenting/znai/core/DocMeta.java index 7e6fb6a12..a21c7eb7c 100644 --- a/znai-core/src/main/java/org/testingisdocumenting/znai/core/DocMeta.java +++ b/znai-core/src/main/java/org/testingisdocumenting/znai/core/DocMeta.java @@ -26,6 +26,7 @@ public class DocMeta { public static final String META_FILE_NAME = "meta.json"; + public static final String TRACK_ACTIVITY_URL_KEY = "trackActivityUrl"; private String id; @@ -114,6 +115,10 @@ public List getAllowedGroups() { return allowedGroups; } + public boolean hasTrackActivityUrl() { + return docMetaMap.containsKey(TRACK_ACTIVITY_URL_KEY); + } + public Map toMap() { Map result = new HashMap<>(); result.put("id", id); diff --git a/znai-docs/znai/release-notes/1.84/add-2025-12-26-stats-page.md b/znai-docs/znai/release-notes/1.84/add-2025-12-26-stats-page.md new file mode 100644 index 000000000..36297ef08 --- /dev/null +++ b/znai-docs/znai/release-notes/1.84/add-2025-12-26-stats-page.md @@ -0,0 +1 @@ +* Add: `/_stats` page to display tracked visitors for internally hosted documentations \ No newline at end of file diff --git a/znai-enterprise-sample-server/znai-enterprise-sample-server.py b/znai-enterprise-sample-server/znai-enterprise-sample-server.py index 96ea07486..7a1037a39 100644 --- a/znai-enterprise-sample-server/znai-enterprise-sample-server.py +++ b/znai-enterprise-sample-server/znai-enterprise-sample-server.py @@ -2,7 +2,8 @@ import json import csv import uuid -from datetime import datetime +from datetime import datetime, timedelta +from collections import defaultdict from flask import Flask, request, jsonify from flask_cors import CORS from slack_sdk import WebClient @@ -248,6 +249,95 @@ def persist_tracking_event(event): writer.writerow(event) +def load_tracking_events(): + if not os.path.exists(TRACKING_CSV_FILE): + return [] + + events = [] + with open(TRACKING_CSV_FILE, 'r', newline='', encoding='utf-8') as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + events.append(row) + + return events + +def calculate_page_stats(events, start_time=None): + page_views = defaultdict(int) + page_unique = defaultdict(set) + + for event in events: + if event.get('eventType') != 'pageOpen': + continue + + timestamp_str = event.get('timestamp', '') + if start_time and timestamp_str: + try: + event_time = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + if event_time < start_time: + continue + except ValueError: + pass + + page_id = event.get('pageId', '') + if not page_id: + continue + + page_views[page_id] += 1 + + data_str = event.get('data', '{}') + try: + data = json.loads(data_str) if data_str else {} + except json.JSONDecodeError: + data = {} + + visitor_id = data.get('visitorId', timestamp_str) + page_unique[page_id].add(visitor_id) + + result = {} + all_pages = set(page_views.keys()) | set(page_unique.keys()) + for page_id in all_pages: + result[page_id] = { + 'totalViews': page_views[page_id], + 'uniqueViews': len(page_unique[page_id]) + } + + return result + +def apply_stats_multiplier(page_stats, multiplier): + result = {} + for page_id, stats in page_stats.items(): + total_views = stats['totalViews'] * multiplier + result[page_id] = { + 'totalViews': int(total_views), + 'uniqueViews': int(total_views * 0.2) + } + return result + +@app.route('/doc-stats', methods=['GET']) +def get_doc_stats(): + try: + events = load_tracking_events() + + now = datetime.utcnow() + week_ago = now - timedelta(days=7) + + week_stats = calculate_page_stats(events, week_ago) + + stats = { + 'week': apply_stats_multiplier(week_stats, 1), + 'month': apply_stats_multiplier(week_stats, 2), + 'year': apply_stats_multiplier(week_stats, 8), + 'total': apply_stats_multiplier(week_stats, 8) + } + + return jsonify(stats), 200 + + except Exception as e: + print(f"Error getting doc stats: {type(e).__name__}: {str(e)}") + import traceback + traceback.print_exc() + return jsonify({"error": str(e)}), 500 + def format_slack_message(username, question, context, page_url): message_parts = [f"@{username} <{page_url}|asked>: {question}"] @@ -256,7 +346,6 @@ def format_slack_message(username, question, context, page_url): return "\n\n".join(message_parts) -# Erase tracking CSV file on server start if os.path.exists(TRACKING_CSV_FILE): os.remove(TRACKING_CSV_FILE) print(f"Erased existing tracking file: {TRACKING_CSV_FILE}") diff --git a/znai-reactjs/src/App.jsx b/znai-reactjs/src/App.jsx index 2cf310b71..0bbe24cc2 100644 --- a/znai-reactjs/src/App.jsx +++ b/znai-reactjs/src/App.jsx @@ -106,6 +106,7 @@ import { jsonPresentationDemo } from "./doc-elements/json/PresentationJson.demo" import { footnoteDemo } from "./doc-elements/footnote/Footnote.demo"; import { asciinemaDemo } from "./doc-elements/asciinema/Asciinema.demo"; import { previewConsoleOutputDemo } from "./screens/preview-change-path/PreviewConsoleOutput.demo"; +import { docStatsViewDemo } from "./screens/doc-stats/DocStatsView.demo"; import { readMoreDemo } from "./doc-elements/read-more/ReadMore.demo.js"; import { snippetsResultOutputDemo } from "./doc-elements/code-snippets/SnippetResultOutput.demo.jsx"; import { createLocalSearchIndex } from "./doc-elements/search/flexSearch.ts"; @@ -241,7 +242,8 @@ registries .registerAsTabs("Landing", landingDemo) .registerAsTabs("Not Authorized", notAuthorizedDemo) .registerAsTabs("Search Popup", searchPopupDemo) - .registerAsTabs("Preview Console Output", previewConsoleOutputDemo); + .registerAsTabs("Preview Console Output", previewConsoleOutputDemo) + .registerAsTabs("Doc Stats", docStatsViewDemo); window.znaiSearchIdx = createLocalSearchIndex(); populateLocalSearchIndexWithData(window.znaiSearchIdx, window.znaiSearchData); diff --git a/znai-reactjs/src/doc-elements/Documentation.jsx b/znai-reactjs/src/doc-elements/Documentation.jsx index 28805813b..bea0748af 100644 --- a/znai-reactjs/src/doc-elements/Documentation.jsx +++ b/znai-reactjs/src/doc-elements/Documentation.jsx @@ -22,7 +22,7 @@ import { themeRegistry } from "../theme/ThemeRegistry"; import SearchPopup from "./search/SearchPopup"; import { getSearchPromise } from "./search/searchPromise"; -import { documentationNavigation } from "../structure/DocumentationNavigation"; +import { documentationNavigation, currentPageId } from "../structure/DocumentationNavigation"; import { documentationTracking } from "./tracking/DocumentationTracking"; import { tableOfContents } from "../structure/toc/TableOfContents"; import { getAllPagesPromise } from "./allPages"; @@ -444,7 +444,7 @@ export class Documentation extends React.Component { document.title = page.tocItem.pageTitle ? docMeta.title + ": " + page.tocItem.pageTitle : docMeta.title; this.setState({ presentationRegistry }, () => { - documentationTracking.onPageOpen(currentPageLocation); + documentationTracking.onPageOpen(currentPageId()); }); } diff --git a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx index d9c54a521..124f8a8e1 100644 --- a/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/SlackActiveQuestions.tsx @@ -15,12 +15,13 @@ */ import React, { useEffect, useState } from "react"; -import { currentPageId, pageIdFromTocItem } from "../../structure/DocumentationNavigation"; +import { currentPageIdWithDocId, pageIdFromTocItem } from "../../structure/DocumentationNavigation"; import { getDocMeta } from "../../structure/docMeta"; import { Notification } from "../../components/Notification"; import { HighlightedText } from "./HighlightedText"; import { TocItem } from "../../structure/TocItem"; import { errorNotifications } from "../../components/DismissableErrorIndicators"; +import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; import { ResolveQuestionButton } from "./ResolveQuestionButton"; import { removeTrailingSlashFromQueryParam } from "./queryParamUtils"; @@ -56,7 +57,7 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode async function fetchActiveQuestions() { try { - const pageId = currentPageId(); + const pageId = currentPageIdWithDocId(); const baseUrl = getDocMeta().slackActiveQuestionsUrl; if (!baseUrl) { return; @@ -64,10 +65,7 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode const url = `${baseUrl}?pageId=${encodeURIComponent(pageId)}&questionId=${questionId}`; - const response = await fetch(url, { - method: "GET", - credentials: "include", - }); + const response = await fetchWithCredentials(url); if (response.ok) { const data = await response.json(); @@ -102,10 +100,12 @@ export function SlackActiveQuestions({ containerNode, tocItem }: { containerNode async function resolveQuestionPost(question: Question) { try { - const response = await fetch(getDocMeta().resolveSlackQuestionUrl! + "/" + question.slackMessageTs, { - method: "POST", - credentials: "include", - }); + const response = await fetchWithCredentials( + getDocMeta().resolveSlackQuestionUrl! + "/" + question.slackMessageTs, + { + method: "POST", + } + ); if (response.ok) { setNotification({ type: "success", message: "Resolved slack question" }); diff --git a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx index 6fc1f69b2..6cdd104b3 100644 --- a/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx +++ b/znai-reactjs/src/doc-elements/text-selection/TextSelectionMenu.tsx @@ -20,10 +20,11 @@ import { findPrefixSuffixAndMatch } from "./textSelectionBuilder"; import { buildHighlightUrl } from "./highlightUrl"; import { getDocMeta } from "../../structure/docMeta"; +import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; import { buildContext } from "./markdownContextBuilder"; import { Notification } from "../../components/Notification"; -import { currentPageId } from "../../structure/DocumentationNavigation"; +import { currentPageIdWithDocId } from "../../structure/DocumentationNavigation"; import "./TextSelectionMenu.css"; export interface TextMenuListener { @@ -248,7 +249,7 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle selectedText: panelData!.prefixSuffixMatch.selection, selectedPrefix: panelData!.prefixSuffixMatch.prefix, selectedSuffix: panelData!.prefixSuffixMatch.suffix, - pageId: currentPageId(), + pageId: currentPageIdWithDocId(), pageOrigin: document.location.origin, slackChannel: getDocMeta().slackChannel, question: question, @@ -256,16 +257,8 @@ export function TextSelectionMenu({ containerNode }: { containerNode: HTMLDivEle }; try { - const headers = getDocMeta().sendToSlackIncludeContentType - ? { - "Content-Type": "application/json", - } - : undefined; - - const response = await fetch(getDocMeta().sendToSlackUrl!, { + const response = await fetchWithCredentials(getDocMeta().sendToSlackUrl!, { method: "POST", - credentials: "include", - headers, body: JSON.stringify(body), }); diff --git a/znai-reactjs/src/doc-elements/tracking/HttpDocumentationTracking.ts b/znai-reactjs/src/doc-elements/tracking/HttpDocumentationTracking.ts index 3cf10587d..491e4f8cb 100644 --- a/znai-reactjs/src/doc-elements/tracking/HttpDocumentationTracking.ts +++ b/znai-reactjs/src/doc-elements/tracking/HttpDocumentationTracking.ts @@ -15,7 +15,8 @@ */ import { DocumentationTrackingListener } from "./DocumentationTracking"; -import { getDocId, getDocMeta } from "../../structure/docMeta"; +import { getDocId } from "../../structure/docMeta"; +import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; export interface TrackingEvent { docId: string; @@ -78,17 +79,9 @@ export class HttpDocumentationTracking implements DocumentationTrackingListener }; try { - const headers = getDocMeta().trackActivityIncludeContentType - ? { - "Content-Type": "application/json", - } - : undefined; - - const response = await fetch(this.trackingUrl, { + const response = await fetchWithCredentials(this.trackingUrl, { method: "POST", - headers: headers, body: JSON.stringify(event), - credentials: "include", }); if (!response.ok) { diff --git a/znai-reactjs/src/index.jsx b/znai-reactjs/src/index.jsx index 155c5db0e..a27a71fa1 100644 --- a/znai-reactjs/src/index.jsx +++ b/znai-reactjs/src/index.jsx @@ -28,6 +28,7 @@ import {Documentation} from "./doc-elements/Documentation"; import {DocumentationPreparationScreen} from './screens/documentation-preparation/DocumentationPreparationScreen' import {PreviewChangeScreen} from './screens/preview-change-path/PreviewChangeScreen' import {NotAuthorizedScreen} from './screens/not-authorized/NotAuthorizedScreen' +import {DocStatsScreen} from './screens/doc-stats/DocStatsScreen' import {Landing} from './screens/landing/Landing' import {themeRegistry} from './theme/ThemeRegistry' import {documentationNavigation} from './structure/DocumentationNavigation.jsx' @@ -42,6 +43,7 @@ window.ReactDOM = ReactDOM window.Documentation = Documentation window.DocumentationPreparationScreen = DocumentationPreparationScreen window.NotAuthorizedScreen = NotAuthorizedScreen +window.DocStatsScreen = DocStatsScreen window.Landing = Landing window.PreviewChangeScreen = PreviewChangeScreen window.themeRegistry = themeRegistry diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx new file mode 100644 index 000000000..35fbd8223 --- /dev/null +++ b/znai-reactjs/src/screens/doc-stats/DocStatsScreen.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useState } from "react"; +import { TocItem } from "../../structure/TocItem"; +import { DocMeta, getDocMeta, mergeDocMeta } from "../../structure/docMeta"; +import { fetchWithCredentials } from "../../utils/fetchWithCredentials"; +import { DocStatsView, PageStats, TimePeriod } from "./DocStatsView"; + +const AVAILABLE_PERIODS: TimePeriod[] = ["week", "month", "year", "total"]; + +export type DocStatsResponse = Record>; + +export interface DocStatsScreenProps { + toc: TocItem[]; + docMeta: DocMeta; +} + +async function fetchDocStats(url: string): Promise { + const response = await fetchWithCredentials(url, {}); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${response.status}`); + } + + return response.json(); +} + +export function DocStatsScreen({ toc, docMeta }: DocStatsScreenProps) { + const [selectedPeriod, setSelectedPeriod] = useState("total"); + const [statsByPeriod, setStatsByPeriod] = useState(null); + const [error, setError] = useState(null); + + // rest of the code in this file and nested components expect global docMeta presence + useEffect(() => { + mergeDocMeta(docMeta); + }, [docMeta]); + + useEffect(() => { + const docStatsUrl = getDocMeta().docStatsUrl; + if (!docStatsUrl) { + return; + } + + fetchDocStats(docStatsUrl) + .then(setStatsByPeriod) + .catch((err) => { + setError(err instanceof Error ? err.message : "Failed to load stats"); + }); + }, []); + + if (error) { + return ( +
+

Failed to load analytics: {error}

+
+ ); + } + + if (!statsByPeriod) { + return null; + } + + const pageStats = statsByPeriod[selectedPeriod] || {}; + + return ( + + ); +} diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.css b/znai-reactjs/src/screens/doc-stats/DocStatsView.css new file mode 100644 index 000000000..ecdd5edc0 --- /dev/null +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.css @@ -0,0 +1,245 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.znai-doc-stats-view { + font-family: var(--znai-regular-font-family); + height: 100vh; + display: flex; + flex-direction: column; + color: var(--znai-regular-text-color); + background: var(--znai-background-color); +} + +.znai-doc-stats-header { + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--znai-toc-panel-border-color); + background: var(--znai-background-color); +} + +.znai-doc-stats-title { + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--znai-section-title-color); +} + +.znai-doc-stats-period-switcher { + display: flex; + gap: 4px; +} + +.znai-doc-stats-period-button { + padding: 6px 14px; + border: 1px solid var(--znai-toc-panel-border-color); + background: var(--znai-background-color); + font-family: var(--znai-regular-font-family); + font-size: var(--znai-smaller-text-size); + color: var(--znai-regular-text-color); + cursor: pointer; + border-radius: 4px; + transition: all 0.15s ease; +} + +.znai-doc-stats-period-button:hover { + border-color: var(--znai-brand-primary-color); + color: var(--znai-brand-primary-color); +} + +.znai-doc-stats-period-button.active { + background: var(--znai-brand-primary-color); + border-color: var(--znai-brand-primary-color); + color: #fff; + font-weight: 500; +} + +.znai-doc-stats-content { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.znai-doc-stats-content:focus { + outline: none; +} + +.znai-doc-stats-overall { + display: flex; + gap: 24px; + flex-wrap: wrap; + max-width: var(--znai-single-column-render-width); + margin: 0 auto 24px; +} + +.znai-doc-stats-overall-stat { + display: flex; + flex-direction: column; + padding: 12px 20px; + background: var(--znai-toc-panel-background-color); + border-radius: 6px; + min-width: 120px; +} + +.znai-doc-stats-overall-value { + font-size: 24px; + font-weight: 600; + color: var(--znai-brand-primary-color); + line-height: 1.2; +} + +.znai-doc-stats-overall-label { + font-size: var(--znai-small-meta-text-size); + color: var(--znai-meta-color); + margin-top: 4px; +} + +.znai-doc-stats-chapters { + display: flex; + flex-direction: column; + gap: 24px; + max-width: var(--znai-single-column-render-width); + margin: 0 auto; +} + +.znai-doc-stats-chapter { + border: 1px solid var(--znai-toc-panel-border-color); + border-radius: 8px; + overflow: hidden; +} + +.znai-doc-stats-chapter-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--znai-toc-panel-background-color); + border-bottom: 1px solid var(--znai-toc-panel-border-color); +} + +.znai-doc-stats-chapter-title { + font-size: var(--znai-smaller-text-size); + font-weight: 600; + color: var(--znai-toc-chapter-title-color); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.znai-doc-stats-chapter-summary { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--znai-small-meta-text-size); + color: var(--znai-meta-color); +} + +.znai-doc-stats-summary-separator { + color: var(--znai-toc-panel-border-color); +} + +.znai-doc-stats-pages { + display: flex; + flex-direction: column; +} + +.znai-doc-stats-page-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--znai-toc-panel-border-color); + transition: background-color 0.15s ease; +} + +.znai-doc-stats-page-item:last-child { + border-bottom: none; +} + +.znai-doc-stats-page-item:hover { + background: var(--znai-toc-panel-background-color); +} + +.znai-doc-stats-page-link { + color: var(--znai-link-color); + text-decoration: none; + font-size: var(--znai-smaller-text-size); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.znai-doc-stats-page-link:hover { + text-decoration: var(--znai-link-decoration); +} + +.znai-doc-stats-counters { + display: flex; + gap: 16px; + flex-shrink: 0; + margin-left: 16px; +} + +.znai-doc-stats-counter { + display: flex; + align-items: baseline; + gap: 4px; +} + +.znai-doc-stats-counter-value { + font-size: var(--znai-smaller-text-size); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.znai-doc-stats-total .znai-doc-stats-counter-value { + color: var(--znai-brand-primary-color); +} + +.znai-doc-stats-unique .znai-doc-stats-counter-value { + color: var(--znai-color-green); +} + +.znai-doc-stats-counter-label { + font-size: var(--znai-small-text-size); + color: var(--znai-meta-color); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.znai-doc-stats-orphaned { + border-color: var(--znai-color-yellow); +} + +.znai-doc-stats-orphaned .znai-doc-stats-chapter-header { + background: color-mix(in srgb, var(--znai-color-yellow) 10%, var(--znai-background-color)); + border-bottom-color: var(--znai-color-yellow); +} + +.znai-doc-stats-orphaned .znai-doc-stats-chapter-title { + color: var(--znai-color-yellow); +} + +.znai-doc-stats-orphaned-page-id { + font-size: var(--znai-smaller-text-size); + color: var(--znai-regular-text-color); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx new file mode 100644 index 000000000..4be714f96 --- /dev/null +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.demo.tsx @@ -0,0 +1,183 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import { Registry, simulateState } from "react-component-viewer"; +import { DocStatsView, PageStats, TimePeriod } from "./DocStatsView"; +import { TocItem } from "../../structure/TocItem"; + +const demoToc: TocItem[] = [ + { + chapterTitle: "", + dirName: "", + fileName: "", + items: [ + { chapterTitle: "", pageTitle: "Index", dirName: "", fileName: "index" }, + { chapterTitle: "", pageTitle: "Getting Started", dirName: "", fileName: "getting-started" }, + ], + }, + { + chapterTitle: "Introduction", + dirName: "introduction", + fileName: "", + items: [ + { chapterTitle: "Introduction", pageTitle: "What Is This", dirName: "introduction", fileName: "what-is-this" }, + { + chapterTitle: "Introduction", + pageTitle: "Installation Guide", + dirName: "introduction", + fileName: "installation", + }, + { + chapterTitle: "Introduction", + pageTitle: "Quick Start Tutorial", + dirName: "introduction", + fileName: "quick-start", + }, + ], + }, + { + chapterTitle: "Core Concepts", + dirName: "core-concepts", + fileName: "", + items: [ + { + chapterTitle: "Core Concepts", + pageTitle: "Architecture Overview", + dirName: "core-concepts", + fileName: "architecture", + }, + { + chapterTitle: "Core Concepts", + pageTitle: "Configuration", + dirName: "core-concepts", + fileName: "configuration", + }, + { chapterTitle: "Core Concepts", pageTitle: "Plugins System", dirName: "core-concepts", fileName: "plugins" }, + { chapterTitle: "Core Concepts", pageTitle: "Theming", dirName: "core-concepts", fileName: "theming" }, + ], + }, + { + chapterTitle: "API Reference", + dirName: "api", + fileName: "", + items: [ + { chapterTitle: "API Reference", pageTitle: "REST Endpoints", dirName: "api", fileName: "rest-endpoints" }, + { chapterTitle: "API Reference", pageTitle: "Authentication", dirName: "api", fileName: "authentication" }, + { chapterTitle: "API Reference", pageTitle: "Error Handling", dirName: "api", fileName: "error-handling" }, + ], + }, + { + chapterTitle: "Advanced Topics", + dirName: "advanced", + fileName: "", + items: [ + { + chapterTitle: "Advanced Topics", + pageTitle: "Performance Optimization", + dirName: "advanced", + fileName: "performance", + }, + { chapterTitle: "Advanced Topics", pageTitle: "Custom Extensions", dirName: "advanced", fileName: "extensions" }, + ], + }, +]; + +const statsByPeriod: Record> = { + week: { + "getting-started": { totalViews: 85, uniqueViews: 42 }, + "introduction/what-is-this": { totalViews: 68, uniqueViews: 35 }, + "introduction/installation": { totalViews: 54, uniqueViews: 28 }, + "introduction/quick-start": { totalViews: 47, uniqueViews: 24 }, + "core-concepts/architecture": { totalViews: 0, uniqueViews: 0 }, + "core-concepts/configuration": { totalViews: 0, uniqueViews: 0 }, + "core-concepts/plugins": { totalViews: 0, uniqueViews: 0 }, + "core-concepts/theming": { totalViews: 0, uniqueViews: 0 }, + "api/rest-endpoints": { totalViews: 43, uniqueViews: 22 }, + "api/authentication": { totalViews: 36, uniqueViews: 19 }, + "api/error-handling": { totalViews: 14, uniqueViews: 7 }, + "advanced/performance": { totalViews: 11, uniqueViews: 5 }, + "advanced/extensions": { totalViews: 6, uniqueViews: 3 }, + "legacy/old-api": { totalViews: 23, uniqueViews: 12 }, + "removed/deprecated-feature": { totalViews: 8, uniqueViews: 4 }, + }, + month: { + "getting-started": { totalViews: 1542, uniqueViews: 893 }, + "introduction/what-is-this": { totalViews: 1235, uniqueViews: 721 }, + "introduction/installation": { totalViews: 987, uniqueViews: 543 }, + "introduction/quick-start": { totalViews: 854, uniqueViews: 432 }, + "core-concepts/architecture": { totalViews: 567, uniqueViews: 345 }, + "core-concepts/configuration": { totalViews: 432, uniqueViews: 234 }, + "core-concepts/plugins": { totalViews: 321, uniqueViews: 198 }, + "core-concepts/theming": { totalViews: 210, uniqueViews: 123 }, + "api/rest-endpoints": { totalViews: 789, uniqueViews: 456 }, + "api/authentication": { totalViews: 654, uniqueViews: 389 }, + "api/error-handling": { totalViews: 234, uniqueViews: 145 }, + "advanced/performance": { totalViews: 189, uniqueViews: 102 }, + "advanced/extensions": { totalViews: 98, uniqueViews: 65 }, + "legacy/old-api": { totalViews: 156, uniqueViews: 89 }, + "removed/deprecated-feature": { totalViews: 67, uniqueViews: 34 }, + }, + year: { + "getting-started": { totalViews: 12000, uniqueViews: 7000 }, + "introduction/what-is-this": { totalViews: 9500, uniqueViews: 5500 }, + "introduction/installation": { totalViews: 7500, uniqueViews: 4200 }, + "introduction/quick-start": { totalViews: 6500, uniqueViews: 3300 }, + "core-concepts/architecture": { totalViews: 4200, uniqueViews: 2600 }, + "core-concepts/configuration": { totalViews: 3200, uniqueViews: 1800 }, + "core-concepts/plugins": { totalViews: 2400, uniqueViews: 1500 }, + "core-concepts/theming": { totalViews: 1600, uniqueViews: 950 }, + "api/rest-endpoints": { totalViews: 5900, uniqueViews: 3400 }, + "api/authentication": { totalViews: 4900, uniqueViews: 2900 }, + "api/error-handling": { totalViews: 1750, uniqueViews: 1100 }, + "advanced/performance": { totalViews: 1400, uniqueViews: 780 }, + "advanced/extensions": { totalViews: 740, uniqueViews: 490 }, + "legacy/old-api": { totalViews: 1230, uniqueViews: 678 }, + "removed/deprecated-feature": { totalViews: 543, uniqueViews: 276 }, + }, + total: { + "getting-started": { totalViews: 15420, uniqueViews: 8934 }, + "introduction/what-is-this": { totalViews: 12350, uniqueViews: 7210 }, + "introduction/installation": { totalViews: 9876, uniqueViews: 5432 }, + "introduction/quick-start": { totalViews: 8543, uniqueViews: 4321 }, + "core-concepts/architecture": { totalViews: 5678, uniqueViews: 3456 }, + "core-concepts/configuration": { totalViews: 4321, uniqueViews: 2345 }, + "core-concepts/plugins": { totalViews: 3210, uniqueViews: 1987 }, + "core-concepts/theming": { totalViews: 2100, uniqueViews: 1234 }, + "api/rest-endpoints": { totalViews: 7890, uniqueViews: 4567 }, + "api/authentication": { totalViews: 6543, uniqueViews: 3890 }, + "api/error-handling": { totalViews: 2345, uniqueViews: 1456 }, + "advanced/performance": { totalViews: 1890, uniqueViews: 1023 }, + "advanced/extensions": { totalViews: 987, uniqueViews: 654 }, + "legacy/old-api": { totalViews: 1567, uniqueViews: 834 }, + "removed/deprecated-feature": { totalViews: 712, uniqueViews: 389 }, + }, +}; + +const [getSelectedPeriod, setSelectedPeriod] = simulateState("total"); + +export function docStatsViewDemo(registry: Registry) { + registry.add("default", () => ( + + )); +} diff --git a/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx b/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx new file mode 100644 index 000000000..e6b4defe4 --- /dev/null +++ b/znai-reactjs/src/screens/doc-stats/DocStatsView.tsx @@ -0,0 +1,266 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useRef } from "react"; +import { TocItem } from "../../structure/TocItem"; +import { documentationNavigation } from "../../structure/DocumentationNavigation"; +import { isTocItemIndex } from "../../structure/toc/TableOfContents"; + +import "./DocStatsView.css"; + +export type TimePeriod = "week" | "month" | "year" | "total"; + +export interface PageStats { + totalViews: number; + uniqueViews: number; +} + +export interface DocStatsViewProps { + guideName: string; + toc: TocItem[]; + pageStats: Record; + selectedPeriod: TimePeriod; + availablePeriods: TimePeriod[]; + onPeriodChange: (period: TimePeriod) => void; +} + +interface PageItemProps { + item: TocItem; + stats?: PageStats; +} + +const TIME_PERIODS: { key: TimePeriod; label: string }[] = [ + { key: "week", label: "Week" }, + { key: "month", label: "Month" }, + { key: "year", label: "Year" }, + { key: "total", label: "Total" }, +]; + +function buildPageId(dirName: string, fileName: string): string { + return dirName ? `${dirName}/${fileName}` : fileName; +} + +function collectTocPageIds(toc: TocItem[]): Set { + const pageIds = new Set(); + for (const chapter of toc) { + for (const item of chapter.items || []) { + if (!isTocItemIndex(item)) { + pageIds.add(buildPageId(item.dirName, item.fileName)); + } + } + } + return pageIds; +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + "M"; + } + if (num >= 1000) { + return (num / 1000).toFixed(1) + "K"; + } + return num.toLocaleString(); +} + +const ZERO_STATS: PageStats = { totalViews: 0, uniqueViews: 0 }; + +function sumStats(statsArray: PageStats[]): PageStats { + return statsArray.reduce( + (acc, stats) => ({ + totalViews: acc.totalViews + stats.totalViews, + uniqueViews: acc.uniqueViews + stats.uniqueViews, + }), + ZERO_STATS + ); +} + +function StatsCounters({ stats }: { stats: PageStats }) { + return ( +
+ + {formatNumber(stats.totalViews)} + views + + + {formatNumber(stats.uniqueViews)} + unique + +
+ ); +} + +function ChapterSummary({ stats }: { stats: PageStats }) { + return ( +
+ {formatNumber(stats.totalViews)} views + | + {formatNumber(stats.uniqueViews)} unique +
+ ); +} + +function OverallStatCard({ value, label }: { value: number; label: string }) { + return ( +
+ {formatNumber(value)} + {label} +
+ ); +} + +function PageItem({ item, stats }: PageItemProps) { + const href = documentationNavigation.buildUrl(item); + + return ( + + ); +} + +interface ChapterSectionProps { + chapter: TocItem; + pageStats: Record; +} + +function ChapterSection({ chapter, pageStats }: ChapterSectionProps) { + const items = (chapter.items || []).filter((item) => !isTocItemIndex(item)); + + if (items.length === 0) { + return null; + } + + const itemStats = items + .map((item) => pageStats[buildPageId(item.dirName, item.fileName)]) + .filter((stats): stats is PageStats => !!stats); + const chapterStats = sumStats(itemStats); + + return ( +
+
+ {chapter.chapterTitle} + +
+
+ {items.map((item) => { + const pageId = buildPageId(item.dirName, item.fileName); + return ; + })} +
+
+ ); +} + +interface OrphanedPagesSectionProps { + orphanedPages: [string, PageStats][]; +} + +function OrphanedPagesSection({ orphanedPages }: OrphanedPagesSectionProps) { + if (orphanedPages.length === 0) { + return null; + } + + const sectionStats = sumStats(orphanedPages.map(([, stats]) => stats)); + + return ( +
+
+ Orphaned Pages + +
+
+ {orphanedPages.map(([pageId, stats]) => ( +
+ {pageId} + +
+ ))} +
+
+ ); +} + +interface TimePeriodSwitcherProps { + selectedPeriod: TimePeriod; + availablePeriods: TimePeriod[]; + onPeriodChange: (period: TimePeriod) => void; +} + +function TimePeriodSwitcher({ selectedPeriod, availablePeriods, onPeriodChange }: TimePeriodSwitcherProps) { + return ( +
+ {TIME_PERIODS.filter((p) => availablePeriods.includes(p.key)).map((period) => ( + + ))} +
+ ); +} + +export function DocStatsView({ + guideName, + toc, + pageStats, + selectedPeriod, + availablePeriods, + onPeriodChange, +}: DocStatsViewProps) { + const contentRef = useRef(null); + + useEffect(() => { + contentRef.current?.focus(); + }, []); + + const totalStats = sumStats(Object.values(pageStats)); + + const tocPageIds = collectTocPageIds(toc); + const orphanedPages = Object.entries(pageStats).filter(([pageId]) => !tocPageIds.has(pageId)); + + return ( +
+
+

{guideName} analytics

+ {availablePeriods.length > 1 && ( + + )} +
+
+
+ + +
+
+ {toc.map((chapter, idx) => ( + + ))} + +
+
+
+ ); +} diff --git a/znai-reactjs/src/structure/DocumentationNavigation.jsx b/znai-reactjs/src/structure/DocumentationNavigation.jsx index 46b23ff61..5559a32b8 100644 --- a/znai-reactjs/src/structure/DocumentationNavigation.jsx +++ b/znai-reactjs/src/structure/DocumentationNavigation.jsx @@ -132,17 +132,22 @@ function joinPageIdParts(docId, dirName, fileName) { return [docId, dirName, fileName].filter((part) => !!part).join("/"); } -function currentPageId() { +function currentPageIdWithDocId() { const pageLocation = documentationNavigation.currentPageLocation(); return isTocItemIndex(pageLocation) ? getDocId() : joinPageIdParts(getDocId(), pageLocation.dirName, pageLocation.fileName); } +function currentPageId() { + const pageLocation = documentationNavigation.currentPageLocation(); + return joinPageIdParts(pageLocation.dirName, pageLocation.fileName); +} + function pageIdFromTocItem(tocItem) { return joinPageIdParts(getDocId(), tocItem.dirName, tocItem.fileName); } const documentationNavigation = new DocumentationNavigation(); -export { documentationNavigation, currentPageId, pageIdFromTocItem }; +export { documentationNavigation, currentPageIdWithDocId, currentPageId, pageIdFromTocItem }; diff --git a/znai-reactjs/src/structure/docMeta.ts b/znai-reactjs/src/structure/docMeta.ts index 931a72b08..40f6a0c8e 100644 --- a/znai-reactjs/src/structure/docMeta.ts +++ b/znai-reactjs/src/structure/docMeta.ts @@ -25,10 +25,10 @@ export interface DocMeta { slackChannel?: string; sendToSlackUrl?: string; slackActiveQuestionsUrl?: string; - sendToSlackIncludeContentType?: boolean; resolveSlackQuestionUrl?: string; trackActivityUrl?: string; - trackActivityIncludeContentType?: boolean; + docStatsUrl?: string; + fetchIncludeContentType?: boolean; useTopHeader?: boolean; hidePresentationTrigger?: boolean; support?: DocMetaSupport; diff --git a/znai-reactjs/src/utils/fetchWithCredentials.ts b/znai-reactjs/src/utils/fetchWithCredentials.ts new file mode 100644 index 000000000..8f0b5b80e --- /dev/null +++ b/znai-reactjs/src/utils/fetchWithCredentials.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDocMeta } from "../structure/docMeta"; + +export function fetchWithCredentials(url: string, options?: RequestInit): Promise { + const includeContentType = getDocMeta().fetchIncludeContentType; + + const headers = + includeContentType && options?.body ? { "Content-Type": "application/json", ...options?.headers } : options?.headers; + + return fetch(url, { + ...options, + credentials: "include", + headers, + }); +} diff --git a/znai-tests/src/test/groovy/scenarios/httpTracking.groovy b/znai-tests/src/test/groovy/scenarios/httpTracking.groovy new file mode 100644 index 000000000..46639fbfd --- /dev/null +++ b/znai-tests/src/test/groovy/scenarios/httpTracking.groovy @@ -0,0 +1,93 @@ +/* + * Copyright 2025 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package scenarios + +import org.testingisdocumenting.webtau.cli.CliBackgroundCommand +import org.testingisdocumenting.webtau.server.WebTauServer + +import static clicommands.CliCommands.znai +import static org.testingisdocumenting.webtau.WebTauGroovyDsl.* +import static pages.Pages.* + +def port = 3460 +def trackingServerPort = 3461 +def capturedEvents = Collections.synchronizedList([]) + +WebTauServer trackingServer +CliBackgroundCommand znaiPreview + +scenario("start tracking fake server") { + def router = server.router() + .post("/track") { request -> + def content = request.getContentAsMap() + capturedEvents.add(content) + return server.response([status: "ok"]) + } + + trackingServer = server.fake("tracking-server", trackingServerPort, router) +} + +scenario("run znai preview with tracking enabled") { + znaiPreview = znai.runInBackground( + "--source=${cfg.fullPath("sampledoc")} --port=$port --preview", + cli.env(ZNAI_TRACK_ACTIVITY_URL: "http://localhost:$trackingServerPort/track")) + znaiPreview.output.waitTo(contain("server started"), 20_000) +} + +scenario("validate initial page load sends tracking event") { + previewServer.openPreviewWithUrl(port, "chapter-one/target") + docContent.title.waitToBe visible + + actual(liveValue{ -> capturedEvents.size() }).waitToBe(greaterThanOrEqual(1)) + + def pageOpenEvent = capturedEvents.find { it.eventType == "pageOpen" } + pageOpenEvent.pageId.should == "chapter-one/target" +} + +scenario("validate TOC navigation sends tracking events") { + standardView.tocItems.get("Links").click() + docContent.title.waitTo == "Links" + + def tocSelectEvents = capturedEvents.findAll { it.eventType == "tocItemSelect" } + tocSelectEvents.size().shouldBe >= 1 +} + +scenario("validate another TOC navigation") { + standardView.tocItems.get("Target").click() + docContent.title.waitTo == "Target" + + def tocSelectEvents = capturedEvents.findAll { it.eventType == "tocItemSelect" } + tocSelectEvents.size().waitTo >= 2 +} + +scenario("validate all captured events") { + def pageOpenEvents = capturedEvents.findAll { it.eventType == "pageOpen" } + pageOpenEvents.size().shouldBe >= 2 + + def pageIds = pageOpenEvents.collect { it.pageId } + pageIds.should contain("chapter-one/links") + pageIds.should contain("chapter-one/target") +} + +scenario("stop servers") { + if (znaiPreview) { + znaiPreview.stop() + } + if (trackingServer) { + trackingServer.stop() + } +} diff --git a/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java b/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java index aa25c092b..62e222f0b 100644 --- a/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java +++ b/znai-website-gen/src/main/java/org/testingisdocumenting/znai/website/WebSite.java @@ -26,6 +26,7 @@ import org.testingisdocumenting.znai.preprocessor.RegexpBasedPreprocessor; import org.testingisdocumenting.znai.resources.*; import org.testingisdocumenting.znai.html.*; +import org.testingisdocumenting.znai.html.reactjs.HtmlReactJsPage; import org.testingisdocumenting.znai.html.reactjs.ReactJsBundle; import org.testingisdocumenting.znai.markdown.PageMarkdownSection; import org.testingisdocumenting.znai.parser.MarkupParser; @@ -205,6 +206,7 @@ private void registerPreprocessor() { public void deploy() { reportPhase("deploying documentation"); generatePages(); + generateDocStatsPage(); generateChapterIndexRedirectPages(); generatePageRedirects(); generateSearchIndex(); @@ -690,6 +692,32 @@ public void buildJsonOfAllPages() { deployer.deploy("all-pages.json", json); } + private void generateDocStatsPage() { + if (!docMeta.hasTrackActivityUrl()) { + return; + } + + reportPhase("generating doc stats page"); + + Map props = new HashMap<>(); + props.put("docMeta", docMeta.toMap()); + props.put("toc", toc.toListOfMaps()); + + HtmlReactJsPage reactJsPage = new HtmlReactJsPage(ReactJsBundle.INSTANCE); + HtmlPage htmlPage = reactJsPage.create( + docMeta.getTitle() + ": Stats", + "DocStatsScreen", + props, + () -> "", + ""); + + extraJavaScriptsInFront.forEach(htmlPage::addJavaScriptInFront); + extraJavaScriptsInBack.forEach(htmlPage::addJavaScript); + + String html = htmlPage.render(docMeta.getId()); + deployer.deploy("_stats/index.html", html); + } + private HtmlPageAndPageProps generatePage(TocItem tocItem, Page page) { try { HtmlPageAndPageProps htmlAndProps = createHtmlPageAndProps(tocItem, page); @@ -1056,9 +1084,9 @@ private void attachSlackMetaFromEnvVars(DocMeta docMeta) { addMetaFromEnvVar(docMeta, "slackChannel", "ZNAI_SLACK_CHANNEL"); addMetaFromEnvVar(docMeta, "slackActiveQuestionsUrl", "ZNAI_SLACK_ACTIVE_QUESTIONS_URL"); addMetaFromEnvVar(docMeta, "resolveSlackQuestionUrl", "ZNAI_RESOLVE_SLACK_QUESTION_URL"); - addMetaFromEnvVar(docMeta, "sendToSlackIncludeContentType", "ZNAI_SEND_TO_SLACK_INCLUDE_CONTENT_TYPE"); - addMetaFromEnvVar(docMeta, "trackActivityUrl", "ZNAI_TRACK_ACTIVITY_URL"); - addMetaFromEnvVar(docMeta, "trackActivityIncludeContentType", "ZNAI_TRACK_ACTIVITY_INCLUDE_CONTENT_TYPE"); + addMetaFromEnvVar(docMeta, DocMeta.TRACK_ACTIVITY_URL_KEY, "ZNAI_TRACK_ACTIVITY_URL"); + addMetaFromEnvVar(docMeta, "docStatsUrl", "ZNAI_DOC_STATS_URL"); + addMetaFromEnvVar(docMeta, "fetchIncludeContentType", "ZNAI_FETCH_INCLUDE_CONTENT_TYPE"); } private void addMetaFromEnvVar(DocMeta docMeta, String key, String envVarName) {