Skip to content

Commit

Permalink
Merge pull request #889 from CodeForAfrica/ui-outline-vpn-user-stats
Browse files Browse the repository at this point in the history
@vpnamanager UI outline vpn user stats
  • Loading branch information
koechkevin authored Sep 19, 2024
2 parents fea32c0 + 60c2318 commit 1020bc6
Show file tree
Hide file tree
Showing 10 changed files with 364 additions and 20 deletions.
2 changes: 2 additions & 0 deletions apps/vpnmanager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@svgr/webpack": "catalog:",
"@types/jest": "catalog:",
"better-sqlite3": "catalog:",
"date-fns": "catalog:",
"googleapis": "catalog:",
"jest": "catalog:",
"next": "catalog:",
Expand All @@ -38,6 +39,7 @@
"devDependencies": {
"@babel/core": "catalog:",
"@commons-ui/testing-library": "workspace:*",
"@types/better-sqlite3": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
Expand Down
283 changes: 283 additions & 0 deletions apps/vpnmanager/src/components/Statistics/Statistics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import React, { useEffect, useRef, useState } from "react";
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TextField,
Grid,
Button,
TablePagination,
} from "@mui/material";
import { Section } from "@commons-ui/core";
import { useRouter } from "next/router";

import { fetchJson, formatBytes } from "@/vpnmanager/utils";
import { Link } from "@commons-ui/next";
import { format, startOfYesterday } from "date-fns";

export interface Data {
ID: number;
userId: string;
usage: number;
date: string;
cumulativeData: number;
email: string;
createdAt: string;
}

interface Props {
data: Data[];
}

const Statistics: React.FC<Props> = ({ data: result }) => {
const router = useRouter();
const yesterday = startOfYesterday();
const [filters, setFilters] = useState({
email: router.query.email || "",
"date.start": router.query["date.start"] || "",
"date.end": router.query["date.end"] || "",
date: router.query.date || format(yesterday, "yyyy-MM-dd"),
orderBy: "date DESC",
});
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [rawData, setData] = useState(result);
const data = rawData.map((d) => ({
...d,
cumulativeData: formatBytes(d.cumulativeData),
usage: formatBytes(d.usage),
}));
const paginatedData = data.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage,
);
useEffect(() => {
(async () => {
try {
const params: Partial<Record<keyof typeof filters, string>> =
Object.fromEntries(
Object.entries(filters).filter(([_, value]) => Boolean(value)),
);
const res = await fetchJson.get("/api/statistics", {
params,
});
setData(res || []);
} catch (error) {
console.error(error);
}
})();
}, [filters]);

const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilters({ ...filters, [e.target.name]: e.target.value });
};

const applyFilters = () => {
const params: Partial<Record<keyof typeof filters, string>> =
Object.fromEntries(
Object.entries(filters).filter(([_, value]) => Boolean(value)),
);

if (Object.keys(params).length) {
router.push(
{
pathname: router.pathname,
query: params,
},
undefined,
{ shallow: true },
);
}
};

useEffect(() => {
setFilters((initial) => ({
...initial,
ID: router.query.ID || "",
userId: router.query.userId || "",
email: router.query.email || "",
"date.start": router.query["date.start"] || "",
"date.end": router.query["date.end"] || "",
orderBy: "date DESC",
}));
}, [router.query]);

const exportRef = useRef<HTMLAnchorElement>();

function exportAsCsv() {
const csvHeaders = [
"ID",
"User ID",
"Email",
"Usage",
"Date",
"Cumulative Data",
"Created At",
];

const csvRows = data.map((row) =>
[
row.ID,
row.userId,
row.email,
row.usage,
row.date,
row.cumulativeData,
row.createdAt,
].join(","),
);

const csvContent = [csvHeaders.join(","), ...csvRows].join("\n");

const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
if (exportRef.current) {
exportRef.current?.setAttribute("href", URL.createObjectURL(blob));
}
}

exportAsCsv();

return (
<TableContainer component={Paper}>
<Section sx={{ px: { xs: 2.5, sm: 0 } }}>
<Grid container spacing={2} padding={2}>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TextField
label="Date"
name="date"
type="date"
InputLabelProps={{ shrink: true }}
variant="outlined"
value={filters["date"]}
onChange={handleFilterChange}
placeholder="Date Start"
size="small"
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TextField
name="email"
variant="outlined"
value={filters.email}
onChange={handleFilterChange}
InputLabelProps={{ shrink: true }}
placeholder="Email"
size="small"
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TextField
name="date.start"
type="date"
InputLabelProps={{ shrink: true }}
variant="outlined"
value={filters["date.start"]}
onChange={handleFilterChange}
placeholder="Date Start"
size="small"
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<TextField
name="date.end"
type="date"
InputLabelProps={{ shrink: true }}
variant="outlined"
value={filters["date.end"]}
onChange={handleFilterChange}
size="small"
placeholder="Date End"
fullWidth
/>
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<Button
sx={{ width: "100%" }}
onClick={applyFilters}
size="small"
variant="contained"
>
Apply Filters
</Button>
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<Link
target="_blank"
ref={exportRef}
href={"#"}
download="statistics.csv"
>
<Button sx={{ width: "100%" }} size="small" variant="contained">
Export as CSV
</Button>
</Link>
</Grid>
</Grid>
<Box sx={{ width: "100%", overflowX: "auto" }}>
<Table>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: "bold" }}>Email</TableCell>
<TableCell sx={{ fontWeight: "bold" }}>Usage</TableCell>
<TableCell sx={{ fontWeight: "bold" }}>Date</TableCell>
<TableCell sx={{ fontWeight: "bold", whiteSpace: "nowrap" }}>
Total Usage(30 days)
</TableCell>

<TableCell sx={{ fontWeight: "bold" }}>Created At</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedData?.length > 0 ? (
paginatedData.map((row) => (
<TableRow key={row.ID}>
<TableCell>{row.email}</TableCell>
<TableCell>{row.usage}</TableCell>
<TableCell>{format(row.date, "yyyy-MM-dd")}</TableCell>
<TableCell>{row.cumulativeData}</TableCell>
<TableCell>
{format(row.createdAt, "yyyy-MM-dd HH:mm")}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} align="center">
No data found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Box>
{data.length ? (
<TablePagination
component="div"
count={data.length}
page={page}
onPageChange={(_, newPage) => setPage(newPage)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(
event: React.ChangeEvent<HTMLInputElement>,
) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
}}
sx={{ alignItems: "center", "*": { m: 0, gap: 2 } }}
rowsPerPageOptions={[5, 10, 25]}
/>
) : null}
</Section>
</TableContainer>
);
};

export default Statistics;
3 changes: 3 additions & 0 deletions apps/vpnmanager/src/components/Statistics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Statistics from "./Statistics";

export default Statistics;
7 changes: 7 additions & 0 deletions apps/vpnmanager/src/lib/data/database.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import betterSqlite3 from "better-sqlite3";
import path from "path";
import fs from "fs";

const dbPath = path.resolve(process.cwd(), "data", "database.sqlite");
const directory = path.dirname(dbPath);

if (!fs.existsSync(directory)) {
fs.mkdirSync(directory, { recursive: true });
}

const db = betterSqlite3(dbPath);

export interface Record {
Expand Down
21 changes: 15 additions & 6 deletions apps/vpnmanager/src/lib/statistics.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { NextApiRequest } from "next/types";
import { OutlineVPN } from "./outline";
import { Filters, Model, Record } from "@/vpnmanager/lib/data/database";
import { format } from "date-fns";

const vpnManager = new OutlineVPN({
apiUrl: process.env.NEXT_APP_VPN_API_URL as string,
});

export async function processUserStats() {
const date = `${new Date().getFullYear()}-${new Date().getMonth() + 1}-${new Date().getDate()}`;
const date: string = format(new Date(), "yyyy-MM-dd");
const { bytesTransferredByUserId = {} } = await vpnManager.getDataUsage();
const allUsers = await vpnManager.getUsers();
const unprocessedUsers: Omit<Record, "ID" | "createdAt">[] = Object.keys(
Expand All @@ -27,11 +28,17 @@ export async function processUserStats() {
return unprocessedUsers;
}

export async function getStats(req: NextApiRequest) {
export async function getStats(
req: NextApiRequest | { query: NextApiRequest["query"] },
) {
const filters: Partial<Filters> & {
"date.start"?: string;
"date.end"?: string;
} = req.query;
const stringDate =
typeof filters.date === "string"
? format(new Date(filters.date), "yyyy-MM-dd")
: undefined;
const validFilters = {
email: filters.email,
ID: filters.ID,
Expand All @@ -41,11 +48,13 @@ export async function getStats(req: NextApiRequest) {
date:
filters["date.start"] && filters["date.end"]
? {
start: filters["date.start"],
end: filters["date.end"],
start: format(
new Date(filters["date.start"]),
"yyyy-MM-dd",
) as string,
end: format(new Date(filters["date.end"]), "yyyy-MM-dd") as string,
}
: filters.date,
: stringDate,
};

return Model.getAll(validFilters);
}
14 changes: 8 additions & 6 deletions apps/vpnmanager/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ export const config = {
};

export function middleware(req: NextRequest) {
const key: string = req.headers.get("x-api-key") as string;
const key: string | null = req.headers.get("x-api-key");
const API_SECRET_KEY = process.env.API_SECRET_KEY;
if (!(key && key === API_SECRET_KEY)) {
return Response.json(
{ success: false, message: "INVALID_API_KEY" },
{ status: 403 },
);
if (req.method !== "GET") {
if (!(key && key === API_SECRET_KEY)) {
return Response.json(
{ success: false, message: "INVALID_API_KEY" },
{ status: 403 },
);
}
}
}
Loading

0 comments on commit 1020bc6

Please sign in to comment.