Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements and bug fixes #87

Merged
merged 10 commits into from
Jan 21, 2025
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
.

Check failure

Code scanning / CodeQL

Remote property injection High

A property name to write to depends on a
user-provided value
.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name is now checked against some regexes that ensure it can only be a valid adapter name. These names can't be used for prototype pollution or similar attacks.

date: 1,

Check failure

Code scanning / CodeQL

Remote property injection High

A property name to write to depends on a
user-provided value
.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name is now checked against some regexes that ensure it can only be a valid adapter name. These names can't be used for prototype pollution or similar attacks.

_id: 0,
})
.sort({ date: -1 })
.limit(1)
.toArray();
if (stats.length === 0) {
res.status(404).send(`Adapter ${name} not found`);
Fixed Show fixed Hide fixed
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);
Fixed Show fixed Hide fixed
}
});

router.get("/api/adapter/:name/stats/history", async function (req, res) {
try {
const { name } = req.params;
const db = await dbConnect();
Expand Down
6 changes: 0 additions & 6 deletions express/backend/src/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,6 @@ async function collectRepos(): Promise<void> {
]);
const collection = db.repoAdapters();

// remove "stale" entries
const { deletedCount } = await collection.deleteMany({
source: { $exists: false },
});
console.log(`Deleted ${deletedCount || 0} stale entries`);

await Promise.all([
addRepoAdapters(collection, latest, "latest"),
addRepoAdapters(collection, stable, "stable"),
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 @@ -180,9 +183,16 @@ export const getWeblateAdapterComponents = AsyncCache.of(async () => {
return components;
});

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 @@
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 @@
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 }}
Fixed Show fixed Hide fixed
/>
</Box>
);
}
Loading