gitscope
← marzneshin / Diff · 6a4fee1 → 6a4fee1
Changes 6a4fee1..6a4fee1
Patch commit
diff --git a/app/db/crud.py b/app/db/crud.py
index 963a3bd..c35acf2 100644
--- a/app/db/crud.py
+++ b/app/db/crud.py
@@ -6,7 +6,7 @@ from enum import Enum
from types import NoneType
from typing import List, Optional, Tuple, Union
-from sqlalchemy import and_, delete, update, select
+from sqlalchemy import and_, delete, update, select, func
from sqlalchemy.orm import Session
from app.db.models import (
@@ -33,6 +33,7 @@ from app.models.node import (
)
from app.models.proxy import InboundHost as InboundHostModify
from app.models.service import Service as ServiceModify, ServiceCreate
+from app.models.system import TrafficUsageSeries
from app.models.user import (
ReminderType,
UserCreate,
@@ -311,13 +312,57 @@ def get_users(
return query.all()
+def get_total_usages(
+ db: Session, admin: Admin, start: datetime, end: datetime
+):
+ usages = defaultdict(int)
+
+ query = (
+ db.query(
+ NodeUserUsage.created_at, func.sum(NodeUserUsage.used_traffic)
+ )
+ .group_by(NodeUserUsage.created_at)
+ .filter(
+ and_(
+ NodeUserUsage.created_at >= start,
+ NodeUserUsage.created_at <= end,
+ )
+ )
+ )
+
+ if not admin.is_sudo:
+ query = (
+ query.filter(
+ Admin.id == admin.id,
+ )
+ .join(User, NodeUserUsage.user_id == User.id)
+ .join(Admin, User.admin_id == Admin.id)
+ )
+ for created_at, used_traffic in query.all():
+ usages[created_at.replace(tzinfo=timezone.utc).timestamp()] += int(
+ used_traffic
+ )
+
+ result = TrafficUsageSeries(usages=[])
+ current = start.astimezone(timezone.utc).replace(
+ minute=0, second=0, microsecond=0
+ )
+
+ while current <= end.replace(tzinfo=timezone.utc):
+ result.usages.append(
+ (int(current.timestamp()), usages.get(current.timestamp()) or 0)
+ )
+ current += timedelta(hours=1)
+
+ return result
+
+
def get_user_usages(
db: Session,
db_user: User,
start: datetime,
end: datetime,
) -> UserUsageSeriesResponse:
- start = start.replace(minute=0, second=0, microsecond=0)
cond = and_(
NodeUserUsage.user_id == db_user.id,
NodeUserUsage.created_at >= start,
diff --git a/app/models/system.py b/app/models/system.py
index 68cf653..e137bc1 100644
--- a/app/models/system.py
+++ b/app/models/system.py
@@ -27,7 +27,6 @@ class UsersStats(BaseModel):
expired: int
limited: int
online: int
- recent_subscription_updates: list[str]
class AdminsStats(BaseModel):
@@ -38,3 +37,7 @@ class NodesStats(BaseModel):
total: int
healthy: int
unhealthy: int
+
+
+class TrafficUsageSeries(BaseModel):
+ usages: list[tuple[int, int]]
diff --git a/app/models/user.py b/app/models/user.py
index 60a8048..7f48f2c 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -146,11 +146,6 @@ class UserResponse(User):
model_config = ConfigDict(from_attributes=True)
-class UserUsage(BaseModel):
- used_traffic: int
- usage_date: datetime
-
-
class UserNodeUsageSeries(BaseModel):
node_id: int | None = None
node_name: str
diff --git a/app/routes/system.py b/app/routes/system.py
index 81d2cb1..134efbd 100644
--- a/app/routes/system.py
+++ b/app/routes/system.py
@@ -1,12 +1,23 @@
from fastapi import APIRouter
from app.db import crud
-from app.db.models import Admin as DBAdmin, User, Settings
+from app.db.models import Admin as DBAdmin, Settings
from app.db.models import Node
-from app.dependencies import DBDep, AdminDep, SudoAdminDep
+from app.dependencies import (
+ DBDep,
+ AdminDep,
+ SudoAdminDep,
+ EndDateDep,
+ StartDateDep,
+)
from app.models.node import NodeStatus
from app.models.settings import SubscriptionSettings, TelegramSettings
-from app.models.system import UsersStats, NodesStats, AdminsStats
+from app.models.system import (
+ UsersStats,
+ NodesStats,
+ AdminsStats,
+ TrafficUsageSeries,
+)
from app.models.user import UserExpireStrategy
router = APIRouter(tags=["System"], prefix="/system")
@@ -60,6 +71,13 @@ def get_nodes_stats(db: DBDep, admin: SudoAdminDep):
)
+@router.get("/stats/traffic", response_model=TrafficUsageSeries)
+def get_total_traffic_stats(
+ db: DBDep, admin: AdminDep, start_date: StartDateDep, end_date: EndDateDep
+):
+ return crud.get_total_usages(db, admin, start_date, end_date)
+
+
@router.get("/stats/users", response_model=UsersStats)
def get_users_stats(db: DBDep, admin: AdminDep):
return UsersStats(
@@ -87,11 +105,4 @@ def get_users_stats(db: DBDep, admin: AdminDep):
online=crud.get_users_count(
db, admin=admin if not admin.is_sudo else None, online=True
),
- recent_subscription_updates=list(
- i[0]
- for i in db.query(User.username)
- .filter(User.sub_updated_at != None)
- .order_by(User.sub_updated_at)
- .limit(5)
- ),
)
diff --git a/dashboard/public/locales/en.json b/dashboard/public/locales/en.json
index b9e344a..b365d3b 100644
--- a/dashboard/public/locales/en.json
+++ b/dashboard/public/locales/en.json
@@ -81,6 +81,15 @@
"donate": "Donate"
},
"page": {
+ "home": {
+ "users-stats": {
+ "desc": "Overview of user's statuses"
+ },
+ "total-traffics": {
+ "title": "Total System Traffic",
+ "desc": "Traffics combined from all nodes"
+ }
+ },
"settings": {
"subscription-settings": {
"template-on-acceptance": "Template on Acceptance",
diff --git a/dashboard/src/common/stats-charts/components/index.ts b/dashboard/src/common/stats-charts/components/index.ts
new file mode 100644
index 0000000..fd69b24
--- /dev/null
+++ b/dashboard/src/common/stats-charts/components/index.ts
@@ -0,0 +1 @@
+export * from "./select-date-view";
diff --git a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/select-date-view.tsx b/dashboard/src/common/stats-charts/components/select-date-view.tsx
similarity index 100%
rename from dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/select-date-view.tsx
rename to dashboard/src/common/stats-charts/components/select-date-view.tsx
diff --git a/dashboard/src/common/stats-charts/hooks/index.ts b/dashboard/src/common/stats-charts/hooks/index.ts
new file mode 100644
index 0000000..2aac369
--- /dev/null
+++ b/dashboard/src/common/stats-charts/hooks/index.ts
@@ -0,0 +1 @@
+export * from "./use-from-now-interval"
diff --git a/dashboard/src/common/stats-charts/hooks/use-from-now-interval.ts b/dashboard/src/common/stats-charts/hooks/use-from-now-interval.ts
new file mode 100644
index 0000000..3338dd0
--- /dev/null
+++ b/dashboard/src/common/stats-charts/hooks/use-from-now-interval.ts
@@ -0,0 +1,13 @@
+import { ChartDateInterval } from "../types";
+
+export function useFromNowInterval(interval: ChartDateInterval) {
+ const start = new Date();
+ const end = new Date();
+ start.setDate(end.getDate() - parseInt(interval.replace("d", "")));
+ end.setMinutes(0, 0, 0);
+ start.setMinutes(0, 0, 0);
+ return {
+ start: start.toISOString(),
+ end: end.toISOString()
+ }
+}
diff --git a/dashboard/src/common/stats-charts/index.ts b/dashboard/src/common/stats-charts/index.ts
new file mode 100644
index 0000000..ea10b00
--- /dev/null
+++ b/dashboard/src/common/stats-charts/index.ts
@@ -0,0 +1,4 @@
+export * from "./components"
+export * from "./hooks"
+export * from "./utils"
+export * from "./types"
diff --git a/dashboard/src/common/stats-charts/types.ts b/dashboard/src/common/stats-charts/types.ts
new file mode 100644
index 0000000..481900c
--- /dev/null
+++ b/dashboard/src/common/stats-charts/types.ts
@@ -0,0 +1,2 @@
+
+export type ChartDateInterval = '90d' | '30d' | '7d' | '1d';
diff --git a/dashboard/src/common/stats-charts/utils/date-x-axies.ts b/dashboard/src/common/stats-charts/utils/date-x-axies.ts
new file mode 100644
index 0000000..b530992
--- /dev/null
+++ b/dashboard/src/common/stats-charts/utils/date-x-axies.ts
@@ -0,0 +1,28 @@
+import { ChartDateInterval } from "../types";
+
+export const dateXAxisTicks = (value: string, timeRange: ChartDateInterval) => {
+ const date = new Date(value);
+ const format = {
+ "90d": {
+ day: "numeric",
+ month: "short",
+ },
+ "30d": {
+ day: "numeric",
+ },
+ "7d": {
+ day: "numeric",
+ hour: "numeric",
+ },
+ "1d": {
+ minute: "numeric",
+ hour: "numeric",
+ }
+ }[timeRange as ChartDateInterval];
+
+ if (timeRange === "1d") {
+ return date.toLocaleTimeString("en-US", format as Intl.DateTimeFormatOptions);
+ }
+
+ return date.toLocaleDateString("en-US", format as Intl.DateTimeFormatOptions);
+}
diff --git a/dashboard/src/common/stats-charts/utils/index.ts b/dashboard/src/common/stats-charts/utils/index.ts
new file mode 100644
index 0000000..2617f36
--- /dev/null
+++ b/dashboard/src/common/stats-charts/utils/index.ts
@@ -0,0 +1 @@
+export * from "./date-x-axies"
diff --git a/dashboard/src/components/page/index.tsx b/dashboard/src/components/page/index.tsx
index 48eae7e..5bba518 100644
--- a/dashboard/src/components/page/index.tsx
+++ b/dashboard/src/components/page/index.tsx
@@ -1,13 +1,41 @@
-import { FC, PropsWithChildren } from 'react'
-import { ScrollArea } from '@marzneshin/components'
+import { FC, PropsWithChildren, HTMLAttributes } from 'react'
+import {
+ Card,
+ CardContent,
+ ScrollArea,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@marzneshin/components';
+import { cn } from '@marzneshin/utils';
-interface PageProps extends PropsWithChildren { }
+interface PageProps {
+ title: JSX.Element | string;
+ content?: JSX.Element | string;
+ footer?: JSX.Element | string;
+}
-export const Page: FC<PageProps> = ({ children }) => {
+export const Page: FC<PageProps & PropsWithChildren & HTMLAttributes<HTMLDivElement>> = ({
+ footer,
+ content,
+ children,
+ title,
+ className
+}) => {
return (
- <ScrollArea className="w-full h-full overflow-y-auto">
+ <ScrollArea className="w-full h-full overflow-auto">
<div className="flex flex-col justify-center items-center h-full w-full">
- {children}
+ <Card className="shadow-none sm:w-screen md:w-full border-none p-0 w-full h-full">
+ <CardHeader className="border-none sm:flex-row">
+ <CardTitle className="flex flex-row justify-start items-center text-2xl text-sans">
+ {title}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className={cn("flex w-full max-w-full", className)}>
+ {content || children}
+ </CardContent>
+ {footer && <CardFooter> {footer} </CardFooter>}
+ </Card>
</div>
</ScrollArea>
);
diff --git a/dashboard/src/components/widgets/section-widget/index.tsx b/dashboard/src/components/widgets/section-widget/index.tsx
index cbb185f..6fd8092 100644
--- a/dashboard/src/components/widgets/section-widget/index.tsx
+++ b/dashboard/src/components/widgets/section-widget/index.tsx
@@ -9,19 +9,20 @@ import {
} from '@marzneshin/components';
import type { FC, PropsWithChildren } from 'react';
-export interface SectionWidgetProps extends PropsWithChildren {
- title: JSX.Element | string;
- description: JSX.Element | string;
- content?: JSX.Element | string;
- footer?: JSX.Element | string;
- options?: JSX.Element | string;
+export interface SectionWidgetProps {
+ title: any;
+ description: any;
+ content?: any;
+ footer?: any;
+ options?: any;
+ className?: string;
}
-export const SectionWidget: FC<SectionWidgetProps> = ({
- options, footer, content, children, title, description
+export const SectionWidget: FC<SectionWidgetProps & PropsWithChildren> = ({
+ options, footer, content, children, title, description, className
}) => {
return (
- <Card>
+ <Card className={className}>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle className="flex flex-row justify-start items-center gap-3 text-xl">
diff --git a/dashboard/src/features/entity-table/entity-table.tsx b/dashboard/src/features/entity-table/entity-table.tsx
index 1336f69..2c47cfc 100644
--- a/dashboard/src/features/entity-table/entity-table.tsx
+++ b/dashboard/src/features/entity-table/entity-table.tsx
@@ -83,7 +83,7 @@ export function EntityTable<T>({
return (
<EntityTableContext.Provider value={contextValue}>
- <div className="flex flex-col">
+ <div className="flex w-full flex-col">
<div className="flex flex-col md:flex-row-reverse items-center py-4 gap-2 w-full">
<div className="flex flex-row items-center w-full">
<DataTableViewOptions table={table} />
diff --git a/dashboard/src/features/total-traffic-widget/api/index.ts b/dashboard/src/features/total-traffic-widget/api/index.ts
new file mode 100644
index 0000000..418cbe5
--- /dev/null
+++ b/dashboard/src/features/total-traffic-widget/api/index.ts
@@ -0,0 +1 @@
+export * from "./total-traffic.query";
diff --git a/dashboard/src/features/total-traffic-widget/api/total-traffic.query.ts b/dashboard/src/features/total-traffic-widget/api/total-traffic.query.ts
new file mode 100644
index 0000000..f8fd0bd
--- /dev/null
+++ b/dashboard/src/features/total-traffic-widget/api/total-traffic.query.ts
@@ -0,0 +1,38 @@
+import { useQuery } from "@tanstack/react-query";
+import { fetch } from "@marzneshin/utils";
+
+export type UsageMetric = number[];
+
+export interface TotalTrafficsResponse {
+ usages: Array<UsageMetric>;
+}
+
+export interface TotalTrafficsQueryOptions {
+ start: string;
+ end: string
+}
+
+export type TotalTrafficsQueryKey = [string, string, string, { start?: string, end?: string }]
+
+export const TotalTrafficsDefault = {
+ usages: []
+}
+
+export async function fetchTotalTraffics({ queryKey }: { queryKey: TotalTrafficsQueryKey }): Promise<TotalTrafficsResponse> {
+ return await fetch(`/system/stats/traffic`, {
+ query: {
+ start: queryKey[3].start,
+ end: queryKey[3].end
+ }
+ }).then((result) => {
+ return result;
+ });
+}
+export const useTotalTrafficQuery = ({ start, end }: TotalTrafficsQueryOptions) => {
+ return useQuery({
+ queryKey: ["system", "stats", "traffic", { start, end }],
+ queryFn: fetchTotalTraffics,
+ refetchInterval: 1000 * 60 * 5, // 60min refresh
+ initialData: TotalTrafficsDefault
+ })
+}
diff --git a/dashboard/src/features/total-traffic-widget/components/index.ts b/dashboard/src/features/total-traffic-widget/components/index.ts
new file mode 100644
index 0000000..d889ad7
--- /dev/null
+++ b/dashboard/src/features/total-traffic-widget/components/index.ts
@@ -0,0 +1 @@
+export * from "./skeleton"
diff --git a/dashboard/src/features/total-traffic-widget/components/skeleton.tsx b/dashboard/src/features/total-traffic-widget/components/skeleton.tsx
new file mode 100644
index 0000000..e2505cb
--- /dev/null
+++ b/dashboard/src/features/total-traffic-widget/components/skeleton.tsx
@@ -0,0 +1,18 @@
+import {
+ Skeleton,
+} from "@marzneshin/components";
+export function UsageGraphSkeleton() {
+ return (
+ <div className="p-4 rounded-lg bg-muted">
+ <div className="mb-4">
+ <Skeleton className="h-6 w-1/4" />
+ </div>
+ <div className="space-y-4">
+ <Skeleton className="h-32 w-full rounded" />
+ <Skeleton className="h-32 w-full rounded" />
+ <Skeleton className="h-32 w-full rounded" />
+ <Skeleton className="h-32 w-full rounded" />
+ </div>
+ </div>
+ )
+}
diff --git a/dashboard/src/features/total-traffic-widget/hooks/index.ts b/dashboard/src/features/total-traffic-widget/hooks/index.ts
new file mode 100644
index 0000000..d8f6e9a
--- /dev/null
+++ b/dashboard/src/features/total-traffic-widget/hooks/index.ts
@@ -0,0 +1 @@
+export * from "./use-transform-data"
diff --git a/dashboard/src/features/total-traffic-widget/hooks/use-transform-data.ts b/dashboard/src/features/total-traffic-widget/hooks/use-transform-data.ts
new file mode 100644
index 0000000..59c4afb
--- /dev/null
+++ b/dashboard/src/features/total-traffic-widget/hooks/use-transform-data.ts
@@ -0,0 +1,17 @@
+import { UsageMetric } from "../api";
+
+export type ChartDataEntry = {
+ datetime: string;
+ [key: string]: number | string;
+};
+
+export type ChartData = ChartDataEntry[];
+
+export function useTransformData(usages: Array<UsageMetric>): ChartData {
+ return usages.map(usageData => {
+ const [timestamp, usage] = usageData;
+ const date = new Date(timestamp * 1000);
+ const formattedDate = date.toISOString();
+ return { datetime: formattedDate, traffic: usage };
+ });
+}
diff --git a/dashboard/src/features/total-traffic-widget/index.tsx b/dashboard/src/features/total-traffic-widget/index.tsx
new file mode 100644
index 0000000..c9ed7d1
--- /dev/null
+++ b/dashboard/src/features/total-traffic-widget/index.tsx
@@ -0,0 +1,107 @@
+import { useState, FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ SectionWidget,
+ ChartContainer,
+ Awaiting,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartConfig,
+} from "@marzneshin/components";
+import { XAxis, BarChart, YAxis, CartesianGrid, Bar } from "recharts"
+import { useTransformData } from "./hooks";
+import { format as formatByte } from '@chbphone55/pretty-bytes';
+import { useTotalTrafficQuery } from "./api";
+import { UsageGraphSkeleton } from "./components";
+import {
+ ChartDateInterval,
+ SelectDateView,
+ dateXAxisTicks,
+ useFromNowInterval
+} from "@marzneshin/common/stats-charts";
+
+const chartConfig = {
+ traffic: {
+ label: "Traffic",
+ color: "hsl(var(--chart-1))",
+ },
+} satisfies ChartConfig
+
+export const TotalTrafficsWidget: FC = () => {
+ const { t } = useTranslation();
+ const [timeRange, setTimeRange] = useState("1d")
+ const { start, end } = useFromNowInterval(timeRange as ChartDateInterval);
+ const { data, isPending } = useTotalTrafficQuery({ start, end })
+ const chartData = useTransformData(data.usages);
+
+ return (
+ <Awaiting
+ Component={
+ <SectionWidget
+ title={
+ <div className="hstack justify-between w-full">
+ {t("page.home.total-traffics.title")}
+ <SelectDateView timeRange={timeRange} setTimeRange={setTimeRange} />
+ </div>
+ }
+ description={t("page.home.total-traffics.desc")}
+ >
+ <ChartContainer
+ config={chartConfig}
+ className="aspect-auto h-[320px] w-full"
+ >
+ <BarChart
+ accessibilityLayer
+ data={chartData}
+ margin={{
+ left: 12,
+ top: 13,
+ right: 12,
+ }}
+ >
+ <CartesianGrid vertical={false} />
+ <YAxis
+ tickLine={false}
+ axisLine={false}
+ tickMargin={8}
+ tickFormatter={(value) => {
+ const [amount, metric] = formatByte(value)
+ return `${amount} ${metric}`
+ }}
+ />
+ <XAxis
+ dataKey="datetime"
+ tickLine={false}
+ axisLine={false}
+ tickMargin={8}
+ tickFormatter={(value) => dateXAxisTicks(value, timeRange as ChartDateInterval)}
+ />
+ <ChartTooltip
+ content={
+ <ChartTooltipContent
+ indicator='line'
+ labelFormatter={(value) => {
+ return new Date(value).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric"
+ })
+ }}
+ valueFormatter={(value) => {
+ const [amount, metric] = formatByte(value as number)
+ return `${amount} ${metric}`
+ }}
+ />
+ }
+ />
+ <Bar dataKey="traffic" fill={`var(--color-traffic)`} />
+ </BarChart>
+ </ChartContainer>
+ </SectionWidget>
+ }
+ Skeleton={<UsageGraphSkeleton />}
+ isFetching={isPending}
+ />
+ );
+};
diff --git a/dashboard/src/features/total-traffic-widget/types.ts b/dashboard/src/features/total-traffic-widget/types.ts
new file mode 100644
index 0000000..a601179
--- /dev/null
+++ b/dashboard/src/features/total-traffic-widget/types.ts
@@ -0,0 +1,10 @@
+import { UserType } from "@marzneshin/modules/users";
+
+export interface NodesUsage {
+ datetime: Date;
+ nodes: Record<string, number>;
+}
+
+export interface UserNodesUsageWidgetProps {
+ user: UserType;
+}
diff --git a/dashboard/src/globals.css b/dashboard/src/globals.css
index 8b06ae8..15ba2f6 100644
--- a/dashboard/src/globals.css
+++ b/dashboard/src/globals.css
@@ -113,3 +113,7 @@
@apply bg-background text-foreground;
}
}
+
+.recharts-responsive-container {
+ height: initial !important;
+}
diff --git a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/filter-data-by-view.ts b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/filter-data-by-view.ts
deleted file mode 100644
index e69de29..0000000
diff --git a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/index.tsx b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/index.tsx
index e19bb7c..a6be3a4 100644
--- a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/index.tsx
+++ b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/index.tsx
@@ -11,9 +11,9 @@ import {
} from "@marzneshin/components";
import { Area, AreaChart, CartesianGrid, YAxis, XAxis } from "recharts"
import { useState } from "react";
-import { SelectDateView } from "./select-date-view";
-import { ChartDateInterval, UserNodesUsageWidgetProps } from "./types";
-import { useChartConfig, useTransformData, useFromNowInterval } from "./hooks";
+import { UserNodesUsageWidgetProps } from "./types";
+import { dateXAxisTicks, useFromNowInterval, SelectDateView, ChartDateInterval } from "@marzneshin/common/stats-charts";
+import { useChartConfig, useTransformData } from "./hooks";
import { format as formatByte } from '@chbphone55/pretty-bytes';
import { useUserNodeUsagesQuery } from "@marzneshin/modules/users";
import { UsageGraphSkeleton } from "./skeleton"
@@ -35,7 +35,6 @@ export const UserNodesUsageWidget: FC<UserNodesUsageWidgetProps> = ({
title={<div className="hstack justify-between w-full">{t("page.users.settings.nodes-usage.title")} <SelectDateView timeRange={timeRange} setTimeRange={setTimeRange} /></div>}
description={t("page.users.settings.nodes-usage.desc")}
>
-
<ChartContainer
className="aspect-auto h-[320px] w-full"
config={config}>
@@ -58,35 +57,12 @@ export const UserNodesUsageWidget: FC<UserNodesUsageWidgetProps> = ({
return `${amount} ${metric}`
}}
/>
-
<XAxis
dataKey="datetime"
tickLine={false}
axisLine={false}
tickMargin={8}
- tickFormatter={(value) => {
- const date = new Date(value);
- const format = {
- "90d": {
- day: "numeric",
- month: "short",
- }, "30d": {
- day: "numeric",
- }, "7d": {
- day: "numeric",
- hour: "numeric",
- },
- "1d": {
- minute: "numeric",
- hour: "numeric",
- }
- }[timeRange as ChartDateInterval];
- if (timeRange === "1d") {
- return date.toLocaleTimeString("en-US", format as Intl.DateTimeFormatOptions);
- }
-
- return date.toLocaleDateString("en-US", format as Intl.DateTimeFormatOptions);
- }}
+ tickFormatter={(value) => dateXAxisTicks(value, timeRange as ChartDateInterval)}
/>
<ChartTooltip
cursor={false}
diff --git a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/mock-data.json b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/mock-data.json
deleted file mode 100644
index 38c7b90..0000000
--- a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/mock-data.json
+++ /dev/null
@@ -1,409 +0,0 @@
-{
- "username": "aueouoeu",
- "node_usages": [
- {
- "node_id": 1,
- "node_name": "Node 1",
- "usages": [
- [
- 1723177800,
- 4321
- ],
- [
- 1723181400,
- 6789
- ],
- [
- 1723185000,
- 10000
- ],
- [
- 1723188600,
- 8210
- ],
- [
- 1723192200,
- 5623
- ],
- [
- 1723195800,
- 2911
- ],
- [
- 1723199400,
- 7456
- ],
- [
- 1723203000,
- 0
- ],
- [
- 1723206600,
- 4983
- ],
- [
- 1723210200,
- 5732
- ],
- [
- 1723213800,
- 6132
- ],
- [
- 1723217400,
- 8723
- ],
- [
- 1723221000,
- 5124
- ],
- [
- 1723224600,
- 4324
- ],
- [
- 1723228200,
- 7651
- ],
- [
- 1723231800,
- 5423
- ],
- [
- 1723235400,
- 0
- ],
- [
- 1723239000,
- 1204
- ],
- [
- 1723242600,
- 0
- ],
- [
- 1723246200,
- 8931
- ],
- [
- 1723249800,
- 4921
- ],
- [
- 1723253400,
- 5401
- ],
- [
- 1723257000,
- 7431
- ],
- [
- 1723260600,
- 4120
- ],
- [
- 1723264200,
- 6634
- ],
- [
- 1723267800,
- 15154
- ],
- [
- 1723271400,
- 4324
- ],
- [
- 1723275000,
- 6212
- ],
- [
- 1723278600,
- 2310
- ],
- [
- 1723282200,
- 8741
- ],
- [
- 1723285800,
- 1201
- ],
- [
- 1723289400,
- 6235
- ],
- [
- 1723293000,
- 3421
- ],
- [
- 1723296600,
- 7632
- ],
- [
- 1723300200,
- 5321
- ],
- [
- 1723303800,
- 7631
- ],
- [
- 1723307400,
- 9832
- ],
- [
- 1723311000,
- 1421
- ],
- [
- 1723314600,
- 0
- ],
- [
- 1723318200,
- 5102
- ],
- [
- 1723321800,
- 6432
- ],
- [
- 1723325400,
- 7321
- ],
- [
- 1723329000,
- 4211
- ],
- [
- 1723332600,
- 5210
- ],
- [
- 1723336200,
- 8732
- ],
- [
- 1723339800,
- 7432
- ],
- [
- 1723343400,
- 1201
- ],
- [
- 1723347000,
- 8643
- ],
- [
- 1723350600,
- 2100
- ]
- ]
- },
- {
- "node_id": 2,
- "node_name": "Node 2",
- "usages": [
- [
- 1723177800,
- 1000
- ],
- [
- 1723181400,
- 1200
- ],
- [
- 1723185000,
- 9000
- ],
- [
- 1723188600,
- 1100
- ],
- [
- 1723192200,
- 950
- ],
- [
- 1723195800,
- 800
- ],
- [
- 1723199400,
- 1000
- ],
- [
- 1723203000,
- 750
- ],
- [
- 1723206600,
- 950
- ],
- [
- 1723210200,
- 1200
- ],
- [
- 1723213800,
- 1150
- ],
- [
- 1723217400,
- 1300
- ],
- [
- 1723221000,
- 1100
- ],
- [
- 1723224600,
- 950
- ],
- [
- 1723228200,
- 900
- ],
- [
- 1723231800,
- 1000
- ],
- [
- 1723235400,
- 0
- ],
- [
- 1723239000,
- 200
- ],
- [
- 1723242600,
- 0
- ],
- [
- 1723246200,
- 1050
- ],
- [
- 1723249800,
- 950
- ],
- [
- 1723253400,
- 850
- ],
- [
- 1723257000,
- 900
- ],
- [
- 1723260600,
- 1000
- ],
- [
- 1723264200,
- 800
- ],
- [
- 1723267800,
- 950
- ],
- [
- 1723271400,
- 1000
- ],
- [
- 1723275000,
- 950
- ],
- [
- 1723278600,
- 700
- ],
- [
- 1723282200,
- 1000
- ],
- [
- 1723285800,
- 900
- ],
- [
- 1723289400,
- 950
- ],
- [
- 1723293000,
- 800
- ],
- [
- 1723296600,
- 1000
- ],
- [
- 1723300200,
- 950
- ],
- [
- 1723303800,
- 900
- ],
- [
- 1723307400,
- 1000
- ],
- [
- 1723311000,
- 300
- ],
- [
- 1723314600,
- 0
- ],
- [
- 1723318200,
- 950
- ],
- [
- 1723321800,
- 800
- ],
- [
- 1723325400,
- 900
- ],
- [
- 1723329000,
- 700
- ],
- [
- 1723332600,
- 800
- ],
- [
- 1723336200,
- 950
- ],
- [
- 1723339800,
- 900
- ],
- [
- 1723343400,
- 300
- ],
- [
- 1723347000,
- 850
- ],
- [
- 1723350600,
- 200
- ]
- ]
- }
- ]
-}
diff --git a/dashboard/src/modules/users/components/widgets/users-stats-widget/index.tsx b/dashboard/src/modules/users/components/widgets/users-stats-widget/index.tsx
index f505048..185a3e2 100644
--- a/dashboard/src/modules/users/components/widgets/users-stats-widget/index.tsx
+++ b/dashboard/src/modules/users/components/widgets/users-stats-widget/index.tsx
@@ -2,48 +2,115 @@ import { FC } from 'react';
import { UsersIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
- MiniWidget,
- PieChart,
+ SectionWidget,
+ ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartLegend,
+ ChartLegendContent,
} from "@marzneshin/components";
-import { UsersStatsLegend } from './legend';
+import { Label, Pie, PieChart } from "recharts"
-interface UsersStatsWidgetProps {
+const chartConfig = {
+ active: {
+ label: "Active",
+ color: "#3b82f6",
+ },
+ online: {
+ label: "Online",
+ color: "#10b981",
+ },
+ expired: {
+ label: "Expired",
+ color: "#4b5563",
+ },
+ onHold: {
+ label: "On Hold",
+ color: "#A855F7",
+ },
+ limited: {
+ label: "Limited",
+ color: "#ef4444",
+ },
+} satisfies ChartConfig;
+
+interface UsersStatsProps {
+ limited: number;
active: number;
- online: number;
expired: number;
on_hold: number;
- limited: number;
+ online: number;
total: number;
}
-export const UsersStatsWidget: FC<UsersStatsWidgetProps> = ({
- active, online, expired, on_hold, limited, total,
-}) => {
+export const UsersStatsWidget: FC<UsersStatsProps> = ({ total, limited, active, expired, on_hold, online }) => {
const { t } = useTranslation();
- const data = [
- { symbol: "Active", amount: active, color: "fill-blue-500" },
- { symbol: "Online", amount: online, color: "fill-emerald-500" },
- { symbol: "Expired", amount: expired, color: "fill-gray-600" },
- { symbol: "On Hold", amount: on_hold, color: "fill-purple-500" },
- { symbol: "Limited", amount: limited, color: "fill-red-500" },
- ];
+ const stats = [
+ { type: "limited", total: limited, fill: "var(--color-limited)" },
+ { type: "active", total: active, fill: "var(--color-active)" },
+ { type: "expired", total: expired, fill: "var(--color-expired)" },
+ { type: "onHold", total: on_hold, fill: "var(--color-onHold)" },
+ { type: "online", total: online, fill: "var(--color-online)" },
+ ]
return (
- <MiniWidget
+ <SectionWidget
title={<> <UsersIcon /> {t('users')} </>}
+ description={t('page.home.users-stats.desc')}
+ className="h-full"
>
- <div className='flex md:flex-row flex-col items-center md:items-start gap-5 justify-between'>
- <PieChart
- data={data}
- width={200}
- height={200}
- total={total}
- label={t('users')}
- />
- <UsersStatsLegend />
- </div>
- </MiniWidget>
+ <ChartContainer
+ config={chartConfig}
+ className="mx-auto aspect-square min-h-[300px] w-full"
+ >
+ <PieChart>
+ <ChartTooltip
+ cursor={false}
+ content={<ChartTooltipContent hideLabel />}
+ />
+ <Pie
+ data={stats}
+ dataKey="total"
+ nameKey="type"
+ innerRadius={60}
+ strokeWidth={5}
+ >
+ <Label
+ content={({ viewBox }) => {
+ if (viewBox && "cx" in viewBox && "cy" in viewBox) {
+ return (
+ <text
+ x={viewBox.cx}
+ y={viewBox.cy}
+ textAnchor="middle"
+ dominantBaseline="middle"
+ >
+ <tspan
+ x={viewBox.cx}
+ y={viewBox.cy}
+ className="fill-foreground text-3xl font-bold"
+ >
+ {total || 0}
+ </tspan>
+ <tspan
+ x={viewBox.cx}
+ y={(viewBox.cy || 0) + 24}
+ className="fill-muted-foreground"
+ >
+ {t('users')}
+ </tspan>
+ </text>
+ )
+ }
+ }}
+ />
+ </Pie>
+ <ChartLegend className="flex -translate-y-2 flex-wrap gap-2 [&>*]:basis-1/5 [&>*]:justify-center text-md" content={<ChartLegendContent />} />
+ </PieChart>
+ </ChartContainer>
+ </SectionWidget>
);
};
diff --git a/dashboard/src/modules/users/components/widgets/users-stats-widget/legend.tsx b/dashboard/src/modules/users/components/widgets/users-stats-widget/legend.tsx
deleted file mode 100644
index 9d20633..0000000
--- a/dashboard/src/modules/users/components/widgets/users-stats-widget/legend.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { FC } from 'react'
-import { scaleOrdinal } from '@visx/scale';
-import { LegendOrdinal, LegendItem, LegendLabel } from '@visx/legend';
-
-const legendGlyphSize = 15;
-
-const ordinalColorScale = scaleOrdinal({
- domain: ["Active", "Online", "Expired", "On Hold", "Limited"],
- range: ['#3b82f6', '#10b981', '#4b5563', '#A855F7', '#ef4444'],
-});
-
-export const UsersStatsLegend: FC = () => {
- return (
- <LegendOrdinal scale={ordinalColorScale}>
- {(labels) => (
- <div className="border border-solid border-[rgba(255, 255, 255, 0.3)] rounded-lg flex flex-row flex-wrap md:flex-col h-full p-3">
- {labels.map((label, i) => (
- <LegendItem
- key={`legend-quantile-${i}`}
- margin="0 5px"
- >
- <svg width={legendGlyphSize} height={legendGlyphSize}>
- <circle
- className="rounded-full"
- fill={label.value}
- r={legendGlyphSize / 2}
- cx={legendGlyphSize / 2}
- cy={legendGlyphSize / 2}
- />
- </svg>
- <LegendLabel align="left" margin="0 0 0 4px">
- {label.text}
- </LegendLabel>
- </LegendItem>
- ))}
- </div>
- )}
- </LegendOrdinal>
- )
-}
diff --git a/dashboard/src/routes/_dashboard/admins.lazy.tsx b/dashboard/src/routes/_dashboard/admins.lazy.tsx
index 9f334e5..d1ea02c 100644
--- a/dashboard/src/routes/_dashboard/admins.lazy.tsx
+++ b/dashboard/src/routes/_dashboard/admins.lazy.tsx
@@ -1,8 +1,4 @@
import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
Page,
Loading,
} from '@marzneshin/components';
@@ -15,20 +11,11 @@ import { useTranslation } from 'react-i18next';
export const AdminsPage: FC = () => {
const { t } = useTranslation();
return (
- <Page>
- <Card className="border-0 sm:w-screen md:w-full">
- <CardHeader>
- <CardTitle>
- {t('admins')}
- </CardTitle>
- </CardHeader>
- <CardContent>
- <AdminsTable />
- <Suspense fallback={<Loading />}>
- <Outlet />
- </Suspense>
- </CardContent>
- </Card>
+ <Page title={t('admins')}>
+ <AdminsTable />
+ <Suspense fallback={<Loading />}>
+ <Outlet />
+ </Suspense>
</Page>
)
};
diff --git a/dashboard/src/routes/_dashboard/hosts.lazy.tsx b/dashboard/src/routes/_dashboard/hosts.lazy.tsx
index fd49fb8..2fbe8f0 100644
--- a/dashboard/src/routes/_dashboard/hosts.lazy.tsx
+++ b/dashboard/src/routes/_dashboard/hosts.lazy.tsx
@@ -1,9 +1,4 @@
import {
- Card,
- CardContent,
- CardFooter,
- CardHeader,
- CardTitle,
Page,
Loading,
} from '@marzneshin/components'
@@ -16,22 +11,11 @@ import { Suspense } from 'react';
export const HostsPage = () => {
const { t } = useTranslation()
return (
- <Page>
- <Card className="border-0 sm:w-screen md:w-full">
- <CardHeader>
- <CardTitle>
- {t('hosts')}
- </CardTitle>
- </CardHeader>
- <CardContent>
- <InboundHostsTable />
- <Suspense fallback={<Loading />}>
- <Outlet />
- </Suspense>
- </CardContent>
- <CardFooter>
- </CardFooter>
- </Card>
+ <Page title={t('hosts')}>
+ <InboundHostsTable />
+ <Suspense fallback={<Loading />}>
+ <Outlet />
+ </Suspense>
</Page>
)
}
diff --git a/dashboard/src/routes/_dashboard/index.lazy.tsx b/dashboard/src/routes/_dashboard/index.lazy.tsx
index b5fdcc9..1df6526 100644
--- a/dashboard/src/routes/_dashboard/index.lazy.tsx
+++ b/dashboard/src/routes/_dashboard/index.lazy.tsx
@@ -1,31 +1,29 @@
import { createFileRoute } from '@tanstack/react-router'
import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
Page,
} from '@marzneshin/components';
import { UsersStatsWidget, useUsersStatsQuery } from '@marzneshin/modules/users';
+import { TotalTrafficsWidget } from '@marzneshin/features/total-traffic-widget';
import { useTranslation } from 'react-i18next';
import { FC } from 'react';
export const DashboardPage: FC = () => {
const { t } = useTranslation();
- const { data: stats } = useUsersStatsQuery()
+ const { data } = useUsersStatsQuery()
return (
- <Page>
- <Card className="border-0 sm:w-screen md:w-full">
- <CardHeader>
- <CardTitle>
- {t('home')}
- </CardTitle>
- </CardHeader>
- <CardContent className="md:flex">
- <UsersStatsWidget {...stats} />
- </CardContent>
- </Card>
+ <Page className="flex flex-col gap-4 w-full" title={t('home')}>
+ <div className={`grid grid-cols-1 md:grid-cols-12 gap-4`}>
+ <div className={data.total !== 0 ? "md:col-span-8" : "md:col-span-12"}>
+ <TotalTrafficsWidget />
+ </div>
+
+ {data.total !== 0 && (
+ <div className="md:col-span-4">
+ <UsersStatsWidget {...data} />
+ </div>
+ )}
+ </div>
</Page>
)
};
diff --git a/dashboard/src/routes/_dashboard/nodes.lazy.tsx b/dashboard/src/routes/_dashboard/nodes.lazy.tsx
index 12ede6a..154ed37 100644
--- a/dashboard/src/routes/_dashboard/nodes.lazy.tsx
+++ b/dashboard/src/routes/_dashboard/nodes.lazy.tsx
@@ -1,10 +1,5 @@
import {
AlertCard,
- Card,
- CardContent,
- CardFooter,
- CardHeader,
- CardTitle,
Page,
Loading,
} from '@marzneshin/components';
@@ -17,32 +12,26 @@ import { SudoRoute } from "@marzneshin/features/sudo-routes";
export const NodesPage: FC = () => {
const { t } = useTranslation();
return (
- <Page>
- <Card className="border-0 sm:w-screen md:w-full">
- <CardHeader>
- <CardTitle>
- {t('nodes')}
- </CardTitle>
- </CardHeader>
- <CardContent>
- <NodesTable />
- <Suspense fallback={<Loading />}>
- <Outlet />
- </Suspense>
- </CardContent>
- <CardFooter>
- <AlertCard
- title={t('page.nodes.certificate-alert.title')}
- desc={
- <>
- {t('page.nodes.certificate-alert.desc')}
- <Link className="m-1 font-semibold text-secondary-foreground" to="/settings">{t('page.nodes.certificate-alert.click')}</Link>
- </>
- }
- />
- </CardFooter>
- </Card >
- </Page>
+ <Page
+ title={t('nodes')}
+ className="sm:w-screen md:w-full"
+ footer={
+ <AlertCard
+ title={t('page.nodes.certificate-alert.title')}
+ desc={
+ <>
+ {t('page.nodes.certificate-alert.desc')}
+ <Link className="m-1 font-semibold text-secondary-foreground" to="/settings">{t('page.nodes.certificate-alert.click')}</Link>
+ </>
+ }
+ />
+ }
+ >
+ <NodesTable />
+ <Suspense fallback={<Loading />}>
+ <Outlet />
+ </Suspense>
+ </Page >
)
};
diff --git a/dashboard/src/routes/_dashboard/services.lazy.tsx b/dashboard/src/routes/_dashboard/services.lazy.tsx
index 7d6c788..dbc2afc 100644
--- a/dashboard/src/routes/_dashboard/services.lazy.tsx
+++ b/dashboard/src/routes/_dashboard/services.lazy.tsx
@@ -1,8 +1,4 @@
import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
Page,
Loading,
} from "@marzneshin/components";
@@ -15,18 +11,11 @@ import { SudoRoute } from "@marzneshin/features/sudo-routes";
export const ServicesPage: FC = () => {
const { t } = useTranslation();
return (
- <Page>
- <Card className="border-0 sm:w-screen md:w-full">
- <CardHeader>
- <CardTitle>{t("services")}</CardTitle>
- </CardHeader>
- <CardContent>
- <ServicesTable />
- <Suspense fallback={<Loading />}>
- <Outlet />
- </Suspense>
- </CardContent>
- </Card>
+ <Page title={t("services")}>
+ <ServicesTable />
+ <Suspense fallback={<Loading />}>
+ <Outlet />
+ </Suspense>
</Page>
);
};
diff --git a/dashboard/src/routes/_dashboard/settings.lazy.tsx b/dashboard/src/routes/_dashboard/settings.lazy.tsx
index 3eae13a..6996961 100644
--- a/dashboard/src/routes/_dashboard/settings.lazy.tsx
+++ b/dashboard/src/routes/_dashboard/settings.lazy.tsx
@@ -1,5 +1,5 @@
-import { Card, CardContent, CardHeader, CardTitle, Page, VStack } from '@marzneshin/components'
+import { Page, VStack } from '@marzneshin/components'
import { CertificateWidget } from '@marzneshin/features/settings';
import { SubscriptionSettingsWidget } from '@marzneshin/features/subscription-settings';
import { createFileRoute } from '@tanstack/react-router';
@@ -9,21 +9,12 @@ import { SudoRoute } from "@marzneshin/features/sudo-routes";
export const Settings = () => {
const { t } = useTranslation()
return (
- <Page>
- <Card className="border-0 sm:w-screen md:w-full">
- <CardHeader>
- <CardTitle>
- {t('settings')}
- </CardTitle>
- </CardHeader>
- <CardContent className="sm:flex flex-col lg:grid grid-cols-2 gap-3 h-full">
- {/* <ConfigurationWidget /> */}
- <VStack className="gap-3">
- <SubscriptionSettingsWidget />
- <CertificateWidget />
- </VStack>
- </CardContent>
- </Card>
+ <Page title={t('settings')} className="sm:flex flex-col lg:grid grid-cols-2 gap-3 h-full">
+ {/* <ConfigurationWidget /> */}
+ <VStack className="gap-3">
+ <SubscriptionSettingsWidget />
+ <CertificateWidget />
+ </VStack>
</Page>
)
}
diff --git a/dashboard/src/routes/_dashboard/users.lazy.tsx b/dashboard/src/routes/_dashboard/users.lazy.tsx
index 80df956..d4cf2ed 100644
--- a/dashboard/src/routes/_dashboard/users.lazy.tsx
+++ b/dashboard/src/routes/_dashboard/users.lazy.tsx
@@ -1,9 +1,4 @@
import {
- Card,
- CardContent,
- CardFooter,
- CardHeader,
- CardTitle,
Page,
Loading,
} from '@marzneshin/components';
@@ -14,24 +9,18 @@ import { useTranslation } from 'react-i18next';
export const UsersPage: FC = () => {
const { t } = useTranslation();
+
return (
- <Page>
- <Card className="border-0 sm:w-screen md:w-full">
- <CardHeader>
- <CardTitle>
- {t('users')}
- </CardTitle>
- </CardHeader>
- <CardContent>
- <UsersTable />
- <Suspense fallback={<Loading />}>
- <Outlet />
- </Suspense>
- </CardContent>
- <CardFooter>
- <UsersNoServiceAlert />
- </CardFooter>
- </Card>
+ <Page
+ title={t('users')}
+ footer={
+ <UsersNoServiceAlert />
+ }
+ >
+ <UsersTable />
+ <Suspense fallback={<Loading />}>
+ <Outlet />
+ </Suspense>
</Page>
)
};