Skip to content

Commit

Permalink
Add current version statistics as a pie chart
Browse files Browse the repository at this point in the history
Closes #60
  • Loading branch information
UncleSamSwiss committed Jan 17, 2025
1 parent dac6865 commit 0207e91
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 43 deletions.
38 changes: 36 additions & 2 deletions express/backend/src/api/adapter.ts
Original file line number Diff line number Diff line change
@@ -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<Statistics>({
adapters: { [name]: 1 },

Check failure

Code scanning / CodeQL

Remote property injection High

A property name to write to depends on a
user-provided value
.
versions: { [name]: 1 },

Check failure

Code scanning / CodeQL

Remote property injection High

A property name to write to depends on a
user-provided value
.
date: 1,
_id: 0,
})
.sort({ date: -1 })
.limit(1)
.toArray();
if (stats.length === 0) {
res.status(404).send(`Adapter ${name} not found`);

Check failure

Code scanning / CodeQL

Reflected cross-site scripting High

Cross-site scripting vulnerability due to a
user-provided value
.
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);

Check warning

Code scanning / CodeQL

Information exposure through a stack trace Medium

This information exposed to the user depends on
stack trace information
.
}
});

router.get("/api/adapter/:name/stats/history", async function (req, res) {
try {
const { name } = req.params;
const db = await dbConnect();
Expand Down
16 changes: 13 additions & 3 deletions express/frontend/src/lib/ioBroker.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<AdapterVersions>(
getApiUrl(`adapter/${uc(adapterName)}/stats/now`),
);
return result.data;
}

export async function getStatisticsHistory(adapterName: string) {
const result = await axios.get<AdapterStats>(
getApiUrl(`adapter/${uc(adapterName)}/stats`),
getApiUrl(`adapter/${uc(adapterName)}/stats/history`),
);
return result.data;
}
Expand Down
4 changes: 2 additions & 2 deletions express/frontend/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -72,7 +72,7 @@ export const router = createBrowserRouter([
},
{
path: "statistics",
element: <AdapterStatistics />,
element: <Statistics />,
},
{
path: "ratings",
Expand Down
2 changes: 1 addition & 1 deletion express/frontend/src/tools/AdapterCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down
19 changes: 8 additions & 11 deletions express/frontend/src/tools/adapter/AdapterDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand All @@ -51,7 +48,7 @@ export function AdapterDashboard() {
to: "releases",
buttons: [<CardButton text="Manage" to="releases" />],
});
if (hasStatistics) {
if (latest[name] || versions?.total) {
generalCards.push({
title: "Statistics",
text: "Learn more about the usage and distribution of your adapter.",
Expand Down
55 changes: 55 additions & 0 deletions express/frontend/src/tools/adapter/statistics/CurrentVersions.tsx
Original file line number Diff line number Diff line change
@@ -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<GraphData>();

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 (
<Box>
<Typography variant="h6" gutterBottom>
Currently installed versions
</Typography>
<Chart
width="100%"
height="300px"
chartType="PieChart"
loader={<CircularProgress size="200px" />}
data={graphData}
options={{
is3D: true,
backgroundColor: "transparent",
sliceVisibilityThreshold: 0.05,
/*colors: [
iconStyles.error.color,
iconStyles.warning.color,
iconStyles.check.color,
],*/
}}
/>
</Box>
);
}
16 changes: 16 additions & 0 deletions express/frontend/src/tools/adapter/statistics/Statistics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Paper } from "@mui/material";
import { VersionHistory } from "./VersionHistory";
import { CurrentVersions } from "./CurrentVersions";

export function Statistics() {
return (
<>
<Paper sx={{ padding: 2 }}>
<CurrentVersions />
</Paper>
<Paper sx={{ padding: 2, marginTop: 2 }}>
<VersionHistory />
</Paper>
</>
);
}
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -56,18 +56,16 @@ const chartDefaults = {
series: [],
};

export interface AdapterStatisticsProps {}

export function AdapterStatistics(props: AdapterStatisticsProps) {
export function VersionHistory() {
const { name } = useAdapter();
const [option, setOption] = useState<any>();
const [showLoading, setShowLoading] = useState(true);

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<string>();
for (const date of Object.keys(stats.counts)) {
Object.keys(stats.counts[date].versions)
Expand Down Expand Up @@ -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 (
<Paper sx={{ padding: 2 }}>
{(option || showLoading) && (
<Box sx={{ marginTop: 2 }}>
<ReactECharts
style={{ height: "400px" }}
loadingOption={{
type: "default",
}}
showLoading={showLoading}
option={option || { ...chartDefaults }}
/>
</Box>
)}
</Paper>
<Box sx={{ marginTop: 2 }}>
<ReactECharts
style={{ height: "400px" }}
loadingOption={{
type: "default",
}}
showLoading={showLoading}
option={option || { ...chartDefaults }}

Check warning

Code scanning / CodeQL

Useless conditional Warning

This use of variable 'option' always evaluates to true.
/>
</Box>
);
}

0 comments on commit 0207e91

Please sign in to comment.