From 0207e919d851eb8db80f72e3de5d5c122b2a6626 Mon Sep 17 00:00:00 2001 From: Samuel Weibel Date: Fri, 17 Jan 2025 12:36:38 +0000 Subject: [PATCH] Add current version statistics as a pie chart Closes #60 --- express/backend/src/api/adapter.ts | 38 ++++++++++++- express/frontend/src/lib/ioBroker.ts | 16 +++++- express/frontend/src/router.tsx | 4 +- express/frontend/src/tools/AdapterCheck.tsx | 2 +- .../src/tools/adapter/AdapterDashboard.tsx | 19 +++---- .../adapter/statistics/CurrentVersions.tsx | 55 +++++++++++++++++++ .../tools/adapter/statistics/Statistics.tsx | 16 ++++++ .../VersionHistory.tsx} | 45 +++++++-------- 8 files changed, 152 insertions(+), 43 deletions(-) create mode 100644 express/frontend/src/tools/adapter/statistics/CurrentVersions.tsx create mode 100644 express/frontend/src/tools/adapter/statistics/Statistics.tsx rename express/frontend/src/tools/adapter/{AdapterStatistics.tsx => statistics/VersionHistory.tsx} (79%) diff --git a/express/backend/src/api/adapter.ts b/express/backend/src/api/adapter.ts index 2ab3406..107a6ed 100644 --- a/express/backend/src/api/adapter.ts +++ b/express/backend/src/api/adapter.ts @@ -1,11 +1,45 @@ import { Router } from "express"; import { dbConnect, unescapeObjectKeys } from "../db/utils"; -import { AdapterStats } from "../global/adapter-stats"; +import { AdapterStats, AdapterVersions } from "../global/adapter-stats"; import { Statistics } from "../global/iobroker"; const router = Router(); -router.get("/api/adapter/:name/stats", async function (req, res) { +router.get("/api/adapter/:name/stats/now", async function (req, res) { + try { + const { name } = req.params; + const db = await dbConnect(); + const rawStatistics = db.rawStatistics(); + + const stats = await rawStatistics + .find() + .project({ + adapters: { [name]: 1 }, + versions: { [name]: 1 }, + date: 1, + _id: 0, + }) + .sort({ date: -1 }) + .limit(1) + .toArray(); + if (stats.length === 0) { + res.status(404).send(`Adapter ${name} not found`); + return; + } + + const stat = unescapeObjectKeys(stats[0]); + const versions: AdapterVersions = { + total: stat.adapters[name] ?? 0, + versions: stat.versions[name] ?? {}, + }; + res.send(versions); + } catch (error: any) { + console.error(error); + res.status(500).send(error.message || error); + } +}); + +router.get("/api/adapter/:name/stats/history", async function (req, res) { try { const { name } = req.params; const db = await dbConnect(); diff --git a/express/frontend/src/lib/ioBroker.ts b/express/frontend/src/lib/ioBroker.ts index e75a701..05d6de2 100644 --- a/express/frontend/src/lib/ioBroker.ts +++ b/express/frontend/src/lib/ioBroker.ts @@ -1,5 +1,8 @@ import axios from "axios"; -import { AdapterStats } from "../../../backend/src/global/adapter-stats"; +import { + AdapterStats, + AdapterVersions, +} from "../../../backend/src/global/adapter-stats"; import { AdapterRatings, AllRatings, @@ -175,9 +178,16 @@ export const getWeblateAdapterComponents = AsyncCache.of(async () => { return result.data; }); -export async function getStatistics(adapterName: string) { +export async function getCurrentVersions(adapterName: string) { + const result = await axios.get( + getApiUrl(`adapter/${uc(adapterName)}/stats/now`), + ); + return result.data; +} + +export async function getStatisticsHistory(adapterName: string) { const result = await axios.get( - getApiUrl(`adapter/${uc(adapterName)}/stats`), + getApiUrl(`adapter/${uc(adapterName)}/stats/history`), ); return result.data; } diff --git a/express/frontend/src/router.tsx b/express/frontend/src/router.tsx index af326d4..147c019 100644 --- a/express/frontend/src/router.tsx +++ b/express/frontend/src/router.tsx @@ -5,7 +5,7 @@ import { UserProvider } from "./contexts/UserContext"; import { AdapterDashboard } from "./tools/adapter/AdapterDashboard"; import { AdapterDetails } from "./tools/adapter/AdapterDetails"; import { AdapterRatings } from "./tools/adapter/AdapterRatings"; -import { AdapterStatistics } from "./tools/adapter/AdapterStatistics"; +import { Statistics } from "./tools/adapter/statistics/Statistics"; import { CreateReleaseDialog } from "./tools/adapter/releases/CreateReleaseDialog"; import { Releases } from "./tools/adapter/releases/Releases"; import { UpdateRepositoriesDialog } from "./tools/adapter/releases/UpdateRepositoriesDialog"; @@ -72,7 +72,7 @@ export const router = createBrowserRouter([ }, { path: "statistics", - element: , + element: , }, { path: "ratings", diff --git a/express/frontend/src/tools/AdapterCheck.tsx b/express/frontend/src/tools/AdapterCheck.tsx index d5a9676..a2464f5 100644 --- a/express/frontend/src/tools/AdapterCheck.tsx +++ b/express/frontend/src/tools/AdapterCheck.tsx @@ -26,7 +26,7 @@ import { useLocation } from "react-router-dom"; import { useUserToken } from "../contexts/UserContext"; import { checkAdapter, CheckResult, getMyAdapterRepos } from "../lib/ioBroker"; -const iconStyles = { +export const iconStyles = { check: { color: "#00b200", }, diff --git a/express/frontend/src/tools/adapter/AdapterDashboard.tsx b/express/frontend/src/tools/adapter/AdapterDashboard.tsx index 3d2603f..a1e678f 100644 --- a/express/frontend/src/tools/adapter/AdapterDashboard.tsx +++ b/express/frontend/src/tools/adapter/AdapterDashboard.tsx @@ -10,7 +10,11 @@ import { CardButton } from "../../components/CardButton"; import { CardGrid, CardGridProps } from "../../components/dashboard/CardGrid"; import { DashboardCardProps } from "../../components/dashboard/DashboardCard"; import { useAdapter } from "../../contexts/AdapterContext"; -import { getAllRatings, getLatest, getStatistics } from "../../lib/ioBroker"; +import { + getAllRatings, + getCurrentVersions, + getLatest, +} from "../../lib/ioBroker"; const CATEGORY_GENERAL = "General"; const CATEGORY_FEATURES = "Features"; @@ -28,18 +32,11 @@ export function AdapterDashboard() { useEffect(() => { setCategories(EMPTY_CARDS); const loadCards = async () => { - const [latest, ratings] = await Promise.all([ + const [latest, ratings, versions] = await Promise.all([ getLatest(), getAllRatings(), + getCurrentVersions(name).catch(() => null), ]); - let hasStatistics = !!latest[name]; - if (!hasStatistics) { - // check if statistics are available - try { - await getStatistics(name); - hasStatistics = true; - } catch {} - } const generalCards: DashboardCardProps[] = []; generalCards.push({ title: "Releases", @@ -51,7 +48,7 @@ export function AdapterDashboard() { to: "releases", buttons: [], }); - if (hasStatistics) { + if (latest[name] || versions?.total) { generalCards.push({ title: "Statistics", text: "Learn more about the usage and distribution of your adapter.", diff --git a/express/frontend/src/tools/adapter/statistics/CurrentVersions.tsx b/express/frontend/src/tools/adapter/statistics/CurrentVersions.tsx new file mode 100644 index 0000000..192dc74 --- /dev/null +++ b/express/frontend/src/tools/adapter/statistics/CurrentVersions.tsx @@ -0,0 +1,55 @@ +import Chart from "react-google-charts"; +import { Box, CircularProgress, Typography } from "@mui/material"; +import { useAdapter } from "../../../contexts/AdapterContext"; +import { useEffect, useState } from "react"; +import { getCurrentVersions } from "../../../lib/ioBroker"; + +type GraphData = [string, string | number][]; + +export function CurrentVersions() { + const { name } = useAdapter(); + const [graphData, setGraphData] = useState(); + + useEffect(() => { + setGraphData(undefined); + const loadHistory = async () => { + const stats = await getCurrentVersions(name); + const data: GraphData = [["Version", "Count"]]; + for (const [version, count] of Object.entries( + stats.versions, + ).reverse()) { + data.push([version, count]); + } + setGraphData(data); + }; + loadHistory().catch((e) => { + console.error(e); + setGraphData(undefined); + }); + }, [name]); + + return ( + + + Currently installed versions + + } + data={graphData} + options={{ + is3D: true, + backgroundColor: "transparent", + sliceVisibilityThreshold: 0.05, + /*colors: [ + iconStyles.error.color, + iconStyles.warning.color, + iconStyles.check.color, + ],*/ + }} + /> + + ); +} diff --git a/express/frontend/src/tools/adapter/statistics/Statistics.tsx b/express/frontend/src/tools/adapter/statistics/Statistics.tsx new file mode 100644 index 0000000..86bb1b7 --- /dev/null +++ b/express/frontend/src/tools/adapter/statistics/Statistics.tsx @@ -0,0 +1,16 @@ +import { Paper } from "@mui/material"; +import { VersionHistory } from "./VersionHistory"; +import { CurrentVersions } from "./CurrentVersions"; + +export function Statistics() { + return ( + <> + + + + + + + + ); +} diff --git a/express/frontend/src/tools/adapter/AdapterStatistics.tsx b/express/frontend/src/tools/adapter/statistics/VersionHistory.tsx similarity index 79% rename from express/frontend/src/tools/adapter/AdapterStatistics.tsx rename to express/frontend/src/tools/adapter/statistics/VersionHistory.tsx index d640702..cc201c9 100644 --- a/express/frontend/src/tools/adapter/AdapterStatistics.tsx +++ b/express/frontend/src/tools/adapter/statistics/VersionHistory.tsx @@ -1,14 +1,14 @@ -import { Box, Paper } from "@mui/material"; +import { Box } from "@mui/material"; import ReactECharts from "echarts-for-react"; import { useEffect, useState } from "react"; import { coerce } from "semver"; import sort from "semver/functions/sort"; -import { useAdapter } from "../../contexts/AdapterContext"; -import { getStatistics } from "../../lib/ioBroker"; +import { useAdapter } from "../../../contexts/AdapterContext"; +import { getStatisticsHistory } from "../../../lib/ioBroker"; const chartDefaults = { title: { - text: "Installed versions", + text: "Installed version history", }, tooltip: { trigger: "axis", @@ -56,9 +56,7 @@ const chartDefaults = { series: [], }; -export interface AdapterStatisticsProps {} - -export function AdapterStatistics(props: AdapterStatisticsProps) { +export function VersionHistory() { const { name } = useAdapter(); const [option, setOption] = useState(); const [showLoading, setShowLoading] = useState(true); @@ -66,8 +64,8 @@ export function AdapterStatistics(props: AdapterStatisticsProps) { useEffect(() => { setOption(undefined); setShowLoading(true); - const loadStatistics = async () => { - const stats = await getStatistics(name); + const loadHistory = async () => { + const stats = await getStatisticsHistory(name); const versions = new Set(); for (const date of Object.keys(stats.counts)) { Object.keys(stats.counts[date].versions) @@ -147,26 +145,25 @@ export function AdapterStatistics(props: AdapterStatisticsProps) { series, }); }; - loadStatistics().catch((e) => { + loadHistory().catch((e) => { console.error(e); setShowLoading(false); setOption(undefined); }); }, [name]); + if (!option || showLoading) { + return null; + } return ( - - {(option || showLoading) && ( - - - - )} - + + + ); }