diff --git a/app/db/crud.py b/app/db/crud.py
@@ -1,6 +1,7 @@
import json
import secrets
-from datetime import datetime, timedelta
+from collections import defaultdict
+from datetime import datetime, timedelta, timezone
from enum import Enum
from types import NoneType
from typing import List, Optional, Tuple, Union
@@ -38,8 +39,9 @@ from app.models.user import (
UserDataUsageResetStrategy,
UserModify,
UserStatus,
- UserUsageResponse,
UserExpireStrategy,
+ UserNodeUsageSeries,
+ UserUsageSeriesResponse,
)
@@ -311,30 +313,44 @@ def get_users(
def get_user_usages(
db: Session,
- dbuser: User,
+ db_user: User,
start: datetime,
end: datetime,
-) -> List[UserUsageResponse]:
- usages = dict()
-
- for node in db.query(Node).all():
- usages[node.id] = UserUsageResponse(
- node_id=node.id, node_name=node.name, used_traffic=0
- )
-
+) -> UserUsageSeriesResponse:
+ start = start.replace(minute=0, second=0, microsecond=0)
cond = and_(
- NodeUserUsage.user_id == dbuser.id,
+ NodeUserUsage.user_id == db_user.id,
NodeUserUsage.created_at >= start,
NodeUserUsage.created_at <= end,
)
+ usages = defaultdict(dict)
+
for v in db.query(NodeUserUsage).filter(cond):
- try:
- usages[v.node_id or 0].used_traffic += v.used_traffic
- except KeyError:
- pass
+ usages[v.node_id][
+ v.created_at.replace(tzinfo=timezone.utc).timestamp()
+ ] = v.used_traffic
+
+ node_ids = list(usages.keys())
+ nodes = db.query(Node).where(Node.id.in_(node_ids))
+ node_id_names = {node.id: node.name for node in nodes}
+
+ result = UserUsageSeriesResponse(username=db_user.username, node_usages=[])
+ for node_id, rows in usages.items():
+ node_usages = UserNodeUsageSeries(
+ node_id=node_id, node_name=node_id_names[node_id], usages=[]
+ )
+ current = start.astimezone(timezone.utc).replace(
+ minute=0, second=0, microsecond=0
+ )
- return list(usages.values())
+ while current <= end:
+ node_usages.usages.append(
+ (int(current.timestamp()), rows.get(current.timestamp()) or 0)
+ )
+ current += timedelta(hours=1)
+ result.node_usages.append(node_usages)
+ return result
def get_users_count(
diff --git a/app/models/user.py b/app/models/user.py
@@ -133,12 +133,17 @@ class UserResponse(User):
model_config = ConfigDict(from_attributes=True)
-class UserUsageResponse(BaseModel):
+class UserUsage(BaseModel):
+ used_traffic: int
+ usage_date: datetime
+
+
+class UserNodeUsageSeries(BaseModel):
node_id: int | None = None
node_name: str
- used_traffic: int
+ usages: list[tuple[int, int]] # list[UserUsage]
-class UserUsagesResponse(BaseModel):
+class UserUsageSeriesResponse(BaseModel):
username: str
- usages: list[UserUsageResponse]
+ node_usages: list[UserNodeUsageSeries]
diff --git a/app/routes/user.py b/app/routes/user.py
@@ -26,7 +26,7 @@ from app.models.user import (
UserCreate,
UserModify,
UserResponse,
- UserUsagesResponse,
+ UserUsageSeriesResponse,
)
from app.utils import report
@@ -418,16 +418,15 @@ async def revoke_user_subscription(
return user
-@router.get("/{username}/usage", response_model=UserUsagesResponse)
+@router.get("/{username}/usage", response_model=UserUsageSeriesResponse)
def get_user_usage(
db: DBDep, db_user: UserDep, start_date: StartDateDep, end_date: EndDateDep
):
"""
Get users usage
"""
- usages = crud.get_user_usages(db, db_user, start_date, end_date)
- return {"usages": usages, "username": db_user.username}
+ return crud.get_user_usages(db, db_user, start_date, end_date)
@router.put("/{username}/set-owner", response_model=UserResponse)
diff --git a/dashboard/package.json b/dashboard/package.json
@@ -75,6 +75,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
+ "d3": "^7.9.0",
"date-fns": "^3.6.0",
"dayjs": "^1.11.11",
"eslint-plugin-react-compiler": "0.0.0-experimental-0998c1e-20240625",
@@ -95,6 +96,7 @@
"react-qr-code": "^2.0.14",
"react-resizable-panels": "^2.0.19",
"react-use-websocket": "^4.8.1",
+ "recharts": "^2.12.7",
"remirror": "^2.0.39",
"sonner": "^1.4.41",
"tailwind-merge": "^2.3.0",
@@ -120,6 +122,7 @@
"@storybook/test-runner": "^0.18.2",
"@tanstack/router-cli": "^1.34.8",
"@tanstack/router-vite-plugin": "^1.34.8",
+ "@types/d3": "^7.4.3",
"@types/node": "^20.14.1",
"@types/react": "^18.3.3",
"@types/react-copy-to-clipboard": "^5.0.7",
diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml
@@ -176,6 +176,9 @@ importers:
cmdk:
specifier: ^1.0.0
version: 1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ d3:
+ specifier: ^7.9.0
+ version: 7.9.0
date-fns:
specifier: ^3.6.0
version: 3.6.0
@@ -236,6 +239,9 @@ importers:
react-use-websocket:
specifier: ^4.8.1
version: 4.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ recharts:
+ specifier: ^2.12.7
+ version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
remirror:
specifier: ^2.0.39
version: 2.0.39(@remirror/pm@2.0.8)(@types/node@20.14.1)(jsdom@24.1.0)(prettier@3.3.2)
@@ -306,6 +312,9 @@ importers:
'@tanstack/router-vite-plugin':
specifier: ^1.34.8
version: 1.34.8(vite@5.2.12(@types/node@20.14.1))
+ '@types/d3':
+ specifier: ^7.4.3
+ version: 7.4.3
'@types/node':
specifier: ^20.14.1
version: 20.14.1
@@ -4297,36 +4306,99 @@ packages:
'@types/d3-array@3.0.3':
resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==}
+ '@types/d3-axis@3.0.6':
+ resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
+
+ '@types/d3-brush@3.0.6':
+ resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
+
+ '@types/d3-chord@3.0.6':
+ resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
+
'@types/d3-color@3.1.0':
resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==}
+ '@types/d3-contour@3.0.6':
+ resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
+
'@types/d3-delaunay@6.0.1':
resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==}
+ '@types/d3-dispatch@3.0.6':
+ resolution: {integrity: sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==}
+
+ '@types/d3-drag@3.0.7':
+ resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+
+ '@types/d3-dsv@3.0.7':
+ resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-fetch@3.0.7':
+ resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
+
+ '@types/d3-force@3.0.10':
+ resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
+
'@types/d3-format@3.0.1':
resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==}
'@types/d3-geo@3.1.0':
resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
+ '@types/d3-hierarchy@3.1.7':
+ resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
+
'@types/d3-interpolate@3.0.1':
resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==}
'@types/d3-path@1.0.11':
resolution: {integrity: sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==}
+ '@types/d3-polygon@3.0.2':
+ resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
+
+ '@types/d3-quadtree@3.0.6':
+ resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
+
+ '@types/d3-random@3.0.3':
+ resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==}
+
+ '@types/d3-scale-chromatic@3.0.3':
+ resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==}
+
'@types/d3-scale@4.0.2':
resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==}
+ '@types/d3-selection@3.0.11':
+ resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
+
'@types/d3-shape@1.3.12':
resolution: {integrity: sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==}
+ '@types/d3-shape@3.1.6':
+ resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==}
+
'@types/d3-time-format@2.1.0':
resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==}
'@types/d3-time@3.0.0':
resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==}
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
+ '@types/d3-transition@3.0.9':
+ resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
+
+ '@types/d3-zoom@3.0.8':
+ resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+
+ '@types/d3@7.4.3':
+ resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
+
'@types/detect-port@1.3.5':
resolution: {integrity: sha512-Rf3/lB9WkDfIL9eEKaSYKc+1L/rNVYBjThk22JTqQw0YozXarX8YljFAz+HCoC6h4B4KwCMsBPZHaFezwT4BNA==}
@@ -5196,6 +5268,10 @@ packages:
resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
engines: {node: '>= 6'}
+ commander@7.2.0:
+ resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
+ engines: {node: '>= 10'}
+
commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
@@ -5296,14 +5372,55 @@ packages:
resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==}
engines: {node: '>=12'}
+ d3-axis@3.0.0:
+ resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
+ engines: {node: '>=12'}
+
+ d3-brush@3.0.0:
+ resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
+ engines: {node: '>=12'}
+
+ d3-chord@3.0.1:
+ resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
+ engines: {node: '>=12'}
+
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
+ d3-contour@4.0.2:
+ resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==}
+ engines: {node: '>=12'}
+
d3-delaunay@6.0.2:
resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==}
engines: {node: '>=12'}
+ d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+
+ d3-drag@3.0.0:
+ resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+ engines: {node: '>=12'}
+
+ d3-dsv@3.0.1:
+ resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-fetch@3.0.1:
+ resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
+ engines: {node: '>=12'}
+
+ d3-force@3.0.0:
+ resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
+ engines: {node: '>=12'}
+
d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
@@ -5312,6 +5429,10 @@ packages:
resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==}
engines: {node: '>=12'}
+ d3-hierarchy@3.1.2:
+ resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
+ engines: {node: '>=12'}
+
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
@@ -5319,13 +5440,41 @@ packages:
d3-path@1.0.9:
resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==}
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-polygon@3.0.1:
+ resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
+ engines: {node: '>=12'}
+
+ d3-quadtree@3.0.1:
+ resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
+ engines: {node: '>=12'}
+
+ d3-random@3.0.1:
+ resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
+ engines: {node: '>=12'}
+
+ d3-scale-chromatic@3.1.0:
+ resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
+ engines: {node: '>=12'}
+
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
+ d3-selection@3.0.0:
+ resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+ engines: {node: '>=12'}
+
d3-shape@1.3.7:
resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==}
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
@@ -5334,6 +5483,24 @@ packages:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
+ d3-transition@3.0.1:
+ resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ d3-selection: 2 - 3
+
+ d3-zoom@3.0.0:
+ resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+ engines: {node: '>=12'}
+
+ d3@7.9.0:
+ resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
+ engines: {node: '>=12'}
+
dash-get@1.0.2:
resolution: {integrity: sha512-4FbVrHDwfOASx7uQVxeiCTo7ggSdYZbqs8lH+WU6ViypPlDbe9y6IP5VVUDQBv9DcnyaiPT5XT0UWHgJ64zLeQ==}
@@ -5368,6 +5535,9 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
+ decimal.js-light@2.5.1:
+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
decimal.js@10.4.3:
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
@@ -5501,6 +5671,9 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+ dom-helpers@5.2.1:
+ resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
+
dom-serializer@0.2.2:
resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
@@ -5732,6 +5905,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
+ eventemitter3@4.0.7:
+ resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@@ -5765,6 +5941,10 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+ fast-equals@5.0.1:
+ resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==}
+ engines: {node: '>=6.0.0'}
+
fast-glob@3.3.2:
resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
engines: {node: '>=8.6.0'}
@@ -7696,6 +7876,12 @@ packages:
peerDependencies:
react-dom: ^16.8.0 || ^17 || ^18
+ react-smooth@4.0.1:
+ resolution: {integrity: sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
+
react-style-singleton@2.2.1:
resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==}
engines: {node: '>=10'}
@@ -7712,6 +7898,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ react-transition-group@4.4.5:
+ resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
+ peerDependencies:
+ react: '>=16.6.0'
+ react-dom: '>=16.6.0'
+
react-use-websocket@4.8.1:
resolution: {integrity: sha512-FTXuG5O+LFozmu1BRfrzl7UIQngECvGJmL7BHsK4TYXuVt+mCizVA8lT0hGSIF0Z0TedF7bOo1nRzOUdginhDw==}
peerDependencies:
@@ -7748,6 +7940,16 @@ packages:
resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==}
engines: {node: '>= 4'}
+ recharts-scale@0.4.5:
+ resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
+
+ recharts@2.12.7:
+ resolution: {integrity: sha512-hlLJMhPQfv4/3NBSAyq3gzGg4h2v69RJh6KU7b3pXYNNAELs9kEoXOjbkxdXpALqKBoVmVptGfLpxdaVYqjmXQ==}
+ engines: {node: '>=14'}
+ peerDependencies:
+ react: ^16.0.0 || ^17.0.0 || ^18.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
+
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@@ -7894,6 +8096,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+ rw@1.3.3:
+ resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+
rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
@@ -8575,6 +8780,9 @@ packages:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
+ victory-vendor@36.9.2:
+ resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
+
vite-node@1.6.0:
resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -15171,34 +15379,125 @@ snapshots:
'@types/d3-array@3.0.3': {}
+ '@types/d3-axis@3.0.6':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-brush@3.0.6':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-chord@3.0.6': {}
+
'@types/d3-color@3.1.0': {}
+ '@types/d3-contour@3.0.6':
+ dependencies:
+ '@types/d3-array': 3.0.3
+ '@types/geojson': 7946.0.14
+
'@types/d3-delaunay@6.0.1': {}
+ '@types/d3-dispatch@3.0.6': {}
+
+ '@types/d3-drag@3.0.7':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-dsv@3.0.7': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-fetch@3.0.7':
+ dependencies:
+ '@types/d3-dsv': 3.0.7
+
+ '@types/d3-force@3.0.10': {}
+
'@types/d3-format@3.0.1': {}
'@types/d3-geo@3.1.0':
dependencies:
'@types/geojson': 7946.0.14
+ '@types/d3-hierarchy@3.1.7': {}
+
'@types/d3-interpolate@3.0.1':
dependencies:
'@types/d3-color': 3.1.0
'@types/d3-path@1.0.11': {}
+ '@types/d3-polygon@3.0.2': {}
+
+ '@types/d3-quadtree@3.0.6': {}
+
+ '@types/d3-random@3.0.3': {}
+
+ '@types/d3-scale-chromatic@3.0.3': {}
+
'@types/d3-scale@4.0.2':
dependencies:
'@types/d3-time': 3.0.0
+ '@types/d3-selection@3.0.11': {}
+
'@types/d3-shape@1.3.12':
dependencies:
'@types/d3-path': 1.0.11
+ '@types/d3-shape@3.1.6':
+ dependencies:
+ '@types/d3-path': 1.0.11
+
'@types/d3-time-format@2.1.0': {}
'@types/d3-time@3.0.0': {}
+ '@types/d3-timer@3.0.2': {}
+
+ '@types/d3-transition@3.0.9':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-zoom@3.0.8':
+ dependencies:
+ '@types/d3-interpolate': 3.0.1
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3@7.4.3':
+ dependencies:
+ '@types/d3-array': 3.0.3
+ '@types/d3-axis': 3.0.6
+ '@types/d3-brush': 3.0.6
+ '@types/d3-chord': 3.0.6
+ '@types/d3-color': 3.1.0
+ '@types/d3-contour': 3.0.6
+ '@types/d3-delaunay': 6.0.1
+ '@types/d3-dispatch': 3.0.6
+ '@types/d3-drag': 3.0.7
+ '@types/d3-dsv': 3.0.7
+ '@types/d3-ease': 3.0.2
+ '@types/d3-fetch': 3.0.7
+ '@types/d3-force': 3.0.10
+ '@types/d3-format': 3.0.1
+ '@types/d3-geo': 3.1.0
+ '@types/d3-hierarchy': 3.1.7
+ '@types/d3-interpolate': 3.0.1
+ '@types/d3-path': 1.0.11
+ '@types/d3-polygon': 3.0.2
+ '@types/d3-quadtree': 3.0.6
+ '@types/d3-random': 3.0.3
+ '@types/d3-scale': 4.0.2
+ '@types/d3-scale-chromatic': 3.0.3
+ '@types/d3-selection': 3.0.11
+ '@types/d3-shape': 3.1.6
+ '@types/d3-time': 3.0.0
+ '@types/d3-time-format': 2.1.0
+ '@types/d3-timer': 3.0.2
+ '@types/d3-transition': 3.0.9
+ '@types/d3-zoom': 3.0.8
+
'@types/detect-port@1.3.5': {}
'@types/diff@5.2.1': {}
@@ -16222,6 +16521,8 @@ snapshots:
commander@6.2.1: {}
+ commander@7.2.0: {}
+
commondir@1.0.1: {}
compressible@2.0.18:
@@ -16334,24 +16635,82 @@ snapshots:
dependencies:
internmap: 2.0.3
+ d3-axis@3.0.0: {}
+
+ d3-brush@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
+ d3-chord@3.0.1:
+ dependencies:
+ d3-path: 3.1.0
+
d3-color@3.1.0: {}
+ d3-contour@4.0.2:
+ dependencies:
+ d3-array: 3.2.1
+
d3-delaunay@6.0.2:
dependencies:
delaunator: 5.0.1
+ d3-dispatch@3.0.1: {}
+
+ d3-drag@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-selection: 3.0.0
+
+ d3-dsv@3.0.1:
+ dependencies:
+ commander: 7.2.0
+ iconv-lite: 0.6.3
+ rw: 1.3.3
+
+ d3-ease@3.0.1: {}
+
+ d3-fetch@3.0.1:
+ dependencies:
+ d3-dsv: 3.0.1
+
+ d3-force@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-quadtree: 3.0.1
+ d3-timer: 3.0.1
+
d3-format@3.1.0: {}
d3-geo@3.1.0:
dependencies:
d3-array: 3.2.1
+ d3-hierarchy@3.1.2: {}
+
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@1.0.9: {}
+ d3-path@3.1.0: {}
+
+ d3-polygon@3.0.1: {}
+
+ d3-quadtree@3.0.1: {}
+
+ d3-random@3.0.1: {}
+
+ d3-scale-chromatic@3.1.0:
+ dependencies:
+ d3-color: 3.1.0
+ d3-interpolate: 3.0.1
+
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.1
@@ -16360,10 +16719,16 @@ snapshots:
d3-time: 3.1.0
d3-time-format: 4.1.0
+ d3-selection@3.0.0: {}
+
d3-shape@1.3.7:
dependencies:
d3-path: 1.0.9
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
@@ -16372,6 +16737,58 @@ snapshots:
dependencies:
d3-array: 3.2.1
+ d3-timer@3.0.1: {}
+
+ d3-transition@3.0.1(d3-selection@3.0.0):
+ dependencies:
+ d3-color: 3.1.0
+ d3-dispatch: 3.0.1
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-timer: 3.0.1
+
+ d3-zoom@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
+ d3@7.9.0:
+ dependencies:
+ d3-array: 3.2.1
+ d3-axis: 3.0.0
+ d3-brush: 3.0.0
+ d3-chord: 3.0.1
+ d3-color: 3.1.0
+ d3-contour: 4.0.2
+ d3-delaunay: 6.0.2
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-dsv: 3.0.1
+ d3-ease: 3.0.1
+ d3-fetch: 3.0.1
+ d3-force: 3.0.0
+ d3-format: 3.1.0
+ d3-geo: 3.1.0
+ d3-hierarchy: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-path: 3.1.0
+ d3-polygon: 3.0.1
+ d3-quadtree: 3.0.1
+ d3-random: 3.0.1
+ d3-scale: 4.0.2
+ d3-scale-chromatic: 3.1.0
+ d3-selection: 3.0.0
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+ d3-timer: 3.0.1
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+ d3-zoom: 3.0.0
+
dash-get@1.0.2: {}
data-urls@5.0.0:
@@ -16393,6 +16810,8 @@ snapshots:
decamelize@1.2.0: {}
+ decimal.js-light@2.5.1: {}
+
decimal.js@10.4.3: {}
dedent@1.5.3(babel-plugin-macros@3.1.0):
@@ -16516,6 +16935,11 @@ snapshots:
dom-accessibility-api@0.6.3: {}
+ dom-helpers@5.2.1:
+ dependencies:
+ '@babel/runtime': 7.24.7
+ csstype: 3.1.3
+
dom-serializer@0.2.2:
dependencies:
domelementtype: 2.3.0
@@ -16786,6 +17210,8 @@ snapshots:
etag@1.8.1: {}
+ eventemitter3@4.0.7: {}
+
execa@5.1.1:
dependencies:
cross-spawn: 7.0.3
@@ -16866,6 +17292,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
+ fast-equals@5.0.1: {}
+
fast-glob@3.3.2:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -19045,6 +19473,14 @@ snapshots:
dependencies:
react-dom: 18.3.1(react@18.3.1)
+ react-smooth@4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ fast-equals: 5.0.1
+ prop-types: 15.8.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+
react-style-singleton@2.2.1(@types/react@18.3.3)(react@18.3.1):
dependencies:
get-nonce: 1.0.1
@@ -19063,6 +19499,15 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
+ react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@babel/runtime': 7.24.7
+ dom-helpers: 5.2.1
+ loose-envify: 1.4.0
+ prop-types: 15.8.1
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
react-use-websocket@4.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
@@ -19117,6 +19562,23 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.6.2
+ recharts-scale@0.4.5:
+ dependencies:
+ decimal.js-light: 2.5.1
+
+ recharts@2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ clsx: 2.1.1
+ eventemitter3: 4.0.7
+ lodash: 4.17.21
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ react-is: 16.13.1
+ react-smooth: 4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ recharts-scale: 0.4.5
+ tiny-invariant: 1.3.3
+ victory-vendor: 36.9.2
+
redent@3.0.0:
dependencies:
indent-string: 4.0.0
@@ -19358,6 +19820,8 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
+ rw@1.3.3: {}
+
rxjs@7.8.1:
dependencies:
tslib: 2.6.2
@@ -20042,6 +20506,23 @@ snapshots:
- '@types/react'
- '@types/react-dom'
+ victory-vendor@36.9.2:
+ dependencies:
+ '@types/d3-array': 3.0.3
+ '@types/d3-ease': 3.0.2
+ '@types/d3-interpolate': 3.0.1
+ '@types/d3-scale': 4.0.2
+ '@types/d3-shape': 3.1.6
+ '@types/d3-time': 3.0.0
+ '@types/d3-timer': 3.0.2
+ d3-array: 3.2.1
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-timer: 3.0.1
+
vite-node@1.6.0(@types/node@20.14.1):
dependencies:
cac: 6.7.14
diff --git a/dashboard/public/locales/en.json b/dashboard/public/locales/en.json
@@ -93,7 +93,8 @@
"update-interval": "Update Interval",
"add-rule": "Add Rule",
"reset-local-changes": "Reset Changes",
- "title": "Subscription Settings",
+ "title": "Subscription",
+ "description": "Manage and customize subscription settings with configurable rules and update options.",
"subscription-title": "Subscription Rules",
"subscription-desc": "Add your subscription rules in order of priority.",
"alert": {
@@ -194,7 +195,15 @@
"revoke_subscription": "Revoke Subscription",
"usage_duration": "Usage Duration",
"activation_deadline": "Activation Deadline",
+ "nodes-usage": "Nodes Usage",
"settings": {
+ "nodes-usage": {
+ "last-days": "Last {{count}} days",
+ "last-months": "Last {{count}} months",
+ "last-hours": "Last {{count}} hours",
+ "title": "User Nodes Usage",
+ "desc": "The amount of user usage of each nodes in time."
+ },
"subscription_link": {
"copied": "Subscription Link Copied",
"copy": "Subscription Link",
diff --git a/dashboard/src/components/ui/chart.tsx b/dashboard/src/components/ui/chart.tsx
@@ -0,0 +1,365 @@
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@marzneshin/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record<keyof typeof THEMES, string> }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext<ChartContextProps | null>(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a <ChartContainer />")
+ }
+
+ return context
+}
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+ }
+>(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+ <ChartContext.Provider value={{ config }}>
+ <div
+ data-chart={chartId}
+ ref={ref}
+ className={cn(
+ "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
+ className
+ )}
+ {...props}
+ >
+ <ChartStyle id={chartId} config={config} />
+ <RechartsPrimitive.ResponsiveContainer>
+ {children}
+ </RechartsPrimitive.ResponsiveContainer>
+ </div>
+ </ChartContext.Provider>
+ )
+})
+ChartContainer.displayName = "Chart"
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+ <style
+ dangerouslySetInnerHTML={{
+ __html: Object.entries(THEMES)
+ .map(
+ ([theme, prefix]) => `
+${prefix} [data-chart=${id}] {
+${colorConfig
+ .map(([key, itemConfig]) => {
+ const color =
+ itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
+ itemConfig.color
+ return color ? ` --color-${key}: ${color};` : null
+ })
+ .join("\n")}
+}
+`
+ )
+ .join("\n"),
+ }}
+ />
+ )
+}
+
+const ChartTooltip = RechartsPrimitive.Tooltip
+
+const ChartTooltipContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
+ React.ComponentProps<"div"> & {
+ hideLabel?: boolean
+ hideIndicator?: boolean
+ indicator?: "line" | "dot" | "dashed"
+ nameKey?: string
+ labelKey?: string
+ valueFormatter?: (value: any) => string
+ }
+>(
+ (
+ {
+ active,
+ payload,
+ className,
+ indicator = "dot",
+ hideLabel = false,
+ hideIndicator = false,
+ label,
+ labelFormatter,
+ valueFormatter,
+ labelClassName,
+ formatter,
+ color,
+ nameKey,
+ labelKey,
+ },
+ ref
+ ) => {
+ const { config } = useChart()
+
+ const tooltipLabel = React.useMemo(() => {
+ if (hideLabel || !payload?.length) {
+ return null
+ }
+
+ const [item] = payload
+ const key = `${labelKey || item.dataKey || item.name || "value"}`
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
+ const value =
+ !labelKey && typeof label === "string"
+ ? config[label as keyof typeof config]?.label || label
+ : itemConfig?.label
+
+ if (labelFormatter) {
+ return (
+ <div className={cn("font-medium", labelClassName)}>
+ {labelFormatter(value, payload)}
+ </div>
+ )
+ }
+
+ if (!value) {
+ return null
+ }
+
+ return <div className={cn("font-medium", labelClassName)}>{value}</div>
+ }, [
+ label,
+ labelFormatter,
+ payload,
+ hideLabel,
+ labelClassName,
+ config,
+ labelKey,
+ ])
+
+ if (!active || !payload?.length) {
+ return null
+ }
+
+ const nestLabel = payload.length === 1 && indicator !== "dot"
+
+ return (
+ <div
+ ref={ref}
+ className={cn(
+ "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
+ className
+ )}
+ >
+ {!nestLabel ? tooltipLabel : null}
+ <div className="grid gap-1.5">
+ {payload.map((item, index) => {
+ const key = `${nameKey || item.name || item.dataKey || "value"}`
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
+ const indicatorColor = color || item.payload.fill || item.color
+
+ return (
+ <div
+ key={item.dataKey}
+ className={cn(
+ "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
+ indicator === "dot" && "items-center"
+ )}
+ >
+ {formatter && item?.value !== undefined && item.name ? (
+ formatter(item.value, item.name, item, index, item.payload)
+ ) : (
+ <>
+ {itemConfig?.icon ? (
+ <itemConfig.icon />
+ ) : (
+ !hideIndicator && (
+ <div
+ className={cn(
+ "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
+ {
+ "h-2.5 w-2.5": indicator === "dot",
+ "w-1": indicator === "line",
+ "w-0 border-[1.5px] border-dashed bg-transparent":
+ indicator === "dashed",
+ "my-0.5": nestLabel && indicator === "dashed",
+ }
+ )}
+ style={
+ {
+ "--color-bg": indicatorColor,
+ "--color-border": indicatorColor,
+ } as React.CSSProperties
+ }
+ />
+ )
+ )}
+ <div
+ className={cn(
+ "flex flex-1 justify-between leading-none",
+ nestLabel ? "items-end" : "items-center"
+ )}
+ >
+ <div className="grid gap-1.5">
+ {nestLabel ? tooltipLabel : null}
+ <span className="text-muted-foreground">
+ {itemConfig?.label || item.name}
+ </span>
+ </div>
+ {item.value && (
+ <span className="font-mono font-medium tabular-nums text-foreground">
+ {valueFormatter ? valueFormatter(item.value) : item.value.toLocaleString()}
+ </span>
+ )}
+ </div>
+ </>
+ )}
+ </div>
+ )
+ })}
+ </div>
+ </div>
+ )
+ }
+)
+ChartTooltipContent.displayName = "ChartTooltip"
+
+const ChartLegend = RechartsPrimitive.Legend
+
+const ChartLegendContent = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> &
+ Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
+ hideIcon?: boolean
+ nameKey?: string
+ }
+>(
+ (
+ { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
+ ref
+ ) => {
+ const { config } = useChart()
+
+ if (!payload?.length) {
+ return null
+ }
+
+ return (
+ <div
+ ref={ref}
+ className={cn(
+ "flex items-center justify-center gap-4",
+ verticalAlign === "top" ? "pb-3" : "pt-3",
+ className
+ )}
+ >
+ {payload.map((item) => {
+ const key = `${nameKey || item.dataKey || "value"}`
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
+
+ return (
+ <div
+ key={item.value}
+ className={cn(
+ "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
+ )}
+ >
+ {itemConfig?.icon && !hideIcon ? (
+ <itemConfig.icon />
+ ) : (
+ <div
+ className="h-2 w-2 shrink-0 rounded-[2px]"
+ style={{
+ backgroundColor: item.color,
+ }}
+ />
+ )}
+ {itemConfig?.label}
+ </div>
+ )
+ })}
+ </div>
+ )
+ }
+)
+ChartLegendContent.displayName = "ChartLegend"
+
+// Helper to extract item config from a payload.
+function getPayloadConfigFromPayload(
+ config: ChartConfig,
+ payload: unknown,
+ key: string
+) {
+ if (typeof payload !== "object" || payload === null) {
+ return undefined
+ }
+
+ const payloadPayload =
+ "payload" in payload &&
+ typeof payload.payload === "object" &&
+ payload.payload !== null
+ ? payload.payload
+ : undefined
+
+ let configLabelKey: string = key
+
+ if (
+ key in payload &&
+ typeof payload[key as keyof typeof payload] === "string"
+ ) {
+ configLabelKey = payload[key as keyof typeof payload] as string
+ } else if (
+ payloadPayload &&
+ key in payloadPayload &&
+ typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
+ ) {
+ configLabelKey = payloadPayload[
+ key as keyof typeof payloadPayload
+ ] as string
+ }
+
+ return configLabelKey in config
+ ? config[configLabelKey]
+ : config[key as keyof typeof config]
+}
+
+export {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ ChartLegend,
+ ChartLegendContent,
+ ChartStyle,
+}
diff --git a/dashboard/src/components/ui/index.ts b/dashboard/src/components/ui/index.ts
@@ -35,3 +35,4 @@ export * from "./accordion";
export * from "./command";
export * from "./sortable";
export * from "./skeleton";
+export * from "./chart";
diff --git a/dashboard/src/components/widgets/section-widget/index.tsx b/dashboard/src/components/widgets/section-widget/index.tsx
@@ -1,6 +1,7 @@
import {
Card,
CardContent,
+ CardDescription,
CardFooter,
CardHeader,
CardTitle,
@@ -10,17 +11,31 @@ 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 const SectionWidget: FC<SectionWidgetProps> = ({ footer, content, children, title }) => {
+export const SectionWidget: FC<SectionWidgetProps> = ({
+ options, footer, content, children, title, description
+}) => {
return (
<Card>
- <CardHeader className="p-4">
- <CardTitle className="flex flex-row justify-start items-center gap-3 text-xl">
- {title}
- </CardTitle>
+ <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">
+ {title}
+ </CardTitle>
+ <CardDescription>
+ {description}
+ </CardDescription>
+ </div>
+ {options &&
+ <div className="flex items-center flex-col justify-center gap-1 px-6 py-5 sm:py-6">
+ {options}
+ </div>
+ }
</CardHeader>
<Separator />
<CardContent className="p-4">
diff --git a/dashboard/src/features/services/components/form-fields/services.tsx b/dashboard/src/features/services/components/form-fields/services.tsx
@@ -18,7 +18,7 @@ import {
useServicesQuery,
type ServiceType,
} from "@marzneshin/features/services";
-import { ServiceCard } from "@marzneshin/features/users";
+import { ServiceCard } from "@marzneshin/modules/users";
import { cn } from "@marzneshin/utils";
import type { FC } from "react";
import {
diff --git a/dashboard/src/features/services/components/tables/inbounds/index.tsx b/dashboard/src/features/services/components/tables/inbounds/index.tsx
@@ -19,7 +19,6 @@ export const ServiceInboundsTable: FC<ServiceInboundsTableProps> = ({
const { t } = useTranslation();
const handleApply = useCallback(() => {
- console.log(selectedInbound)
updateService({ id: service.id, name: service.name, inbound_ids: selectedInbound });
}, [selectedInbound, service, updateService]);
diff --git a/dashboard/src/features/services/components/tables/users/columns.tsx b/dashboard/src/features/services/components/tables/users/columns.tsx
@@ -7,7 +7,7 @@ import {
UserUsedTraffic,
UserExpirationValue,
userTrafficSortingFn,
-} from "@marzneshin/features/users"
+} from "@marzneshin/modules/users"
export const columns: ColumnDef<UserType>[] = [
{
diff --git a/dashboard/src/features/services/services/service-users.query.ts b/dashboard/src/features/services/services/service-users.query.ts
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@marzneshin/utils";
-import { UserType } from "@marzneshin/features/users";
+import { UserType } from "@marzneshin/modules/users";
import type {
DoubleEntityQueryKeyType,
UseEntityQueryProps,
diff --git a/dashboard/src/features/settings/widgets/configuration.tsx b/dashboard/src/features/settings/widgets/configuration.tsx
@@ -3,7 +3,7 @@ import { SectionWidget } from "@marzneshin/components";
export const ConfigurationWidget = () => {
return (
- <SectionWidget title="System Configuration">
+ <SectionWidget description="" title="System Configuration">
Config fields
</SectionWidget>
)
diff --git a/dashboard/src/features/subscription-settings/widget/index.tsx b/dashboard/src/features/subscription-settings/widget/index.tsx
@@ -8,15 +8,9 @@ export const SubscriptionSettingsWidget = () => {
const { t } = useTranslation()
return (
<SectionWidget
- title={
- <div className="space-y-1">
- <h4>
- {t("page.settings.subscription-settings.title")}
- </h4>
- </div>
- }
- >
- <SubscriptionRulesForm />
- </SectionWidget>
+ title={t("page.settings.subscription-settings.title")}
+ description={t("page.settings.subscription-settings.description")}
+ content={<SubscriptionRulesForm />}
+ />
)
}
diff --git a/dashboard/src/features/users/components/widgets/index.ts b/dashboard/src/features/users/components/widgets/index.ts
@@ -1 +0,0 @@
-export * from './users-stats-widget'
diff --git a/dashboard/src/features/users/domain/index.ts b/dashboard/src/features/users/domain/index.ts
@@ -1 +0,0 @@
-export * from "./user-traffic-sorting-fn"
diff --git a/dashboard/src/features/users/services/index.ts b/dashboard/src/features/users/services/index.ts
@@ -1,9 +0,0 @@
-export * from "./users.query";
-export * from "./user.query";
-export * from "./users-stats.query";
-export * from "./update-user.mutate";
-export * from "./create-user.mutate";
-export * from "./delete-user.mutate";
-export * from "./user-usage-reset.command";
-export * from "./user-subscription-revoke.command";
-export * from "./user-status.mutate";
diff --git a/dashboard/src/features/users/types/user-prop.ts b/dashboard/src/features/users/types/user-prop.ts
@@ -1,5 +0,0 @@
-import { UserType } from "@marzneshin/features/users";
-
-export interface UserProp {
- user: UserType;
-}
diff --git a/dashboard/src/globals.css b/dashboard/src/globals.css
@@ -38,6 +38,12 @@
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
+
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
}
.dark {
@@ -73,6 +79,12 @@
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
+
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
}
}
diff --git a/dashboard/src/modules/admins/components/dialogs/mutation/index.tsx b/dashboard/src/modules/admins/components/dialogs/mutation/index.tsx
@@ -18,7 +18,7 @@ import {
AdminType,
} from "@marzneshin/modules/admins";
import { ServicesField } from "@marzneshin/features/services";
-import { UsernameField } from "@marzneshin/features/users";
+import { UsernameField } from "@marzneshin/modules/users";
import {
PasswordField,
EnabledField,
diff --git a/dashboard/src/features/users/services/create-user.mutate.ts b/dashboard/src/modules/users/api/commands/create-user.command.ts
rename from dashboard/src/features/users/services/create-user.mutate.ts
rename to dashboard/src/modules/users/api/commands/create-user.command.ts
@@ -1,4 +1,4 @@
-import { UserMutationType, UsersQueryFetchKey } from "@marzneshin/features/users";
+import { UserMutationType, UsersQueryFetchKey } from "@marzneshin/modules/users";
import { useMutation } from "@tanstack/react-query";
import { fetch, queryClient } from "@marzneshin/utils";
import { toast } from "sonner";
diff --git a/dashboard/src/features/users/services/delete-user.mutate.ts b/dashboard/src/modules/users/api/commands/delete-user.command.ts
rename from dashboard/src/features/users/services/delete-user.mutate.ts
rename to dashboard/src/modules/users/api/commands/delete-user.command.ts
@@ -1,4 +1,4 @@
-import { UserMutationType, UsersQueryFetchKey } from "@marzneshin/features/users";
+import { UserMutationType, UsersQueryFetchKey } from "@marzneshin/modules/users";
import { useMutation } from "@tanstack/react-query";
import { fetch, queryClient } from "@marzneshin/utils";
import { toast } from "sonner";
diff --git a/dashboard/src/modules/users/api/commands/index.ts b/dashboard/src/modules/users/api/commands/index.ts
@@ -0,0 +1,6 @@
+export * from "./create-user.command"
+export * from "./update-user.command"
+export * from "./delete-user.command"
+export * from "./user-status.command"
+export * from "./user-usage-reset.command"
+export * from "./user-subscription-revoke.command"
diff --git a/dashboard/src/features/users/services/update-user.mutate.ts b/dashboard/src/modules/users/api/commands/update-user.command.ts
rename from dashboard/src/features/users/services/update-user.mutate.ts
rename to dashboard/src/modules/users/api/commands/update-user.command.ts
@@ -5,7 +5,7 @@ import i18n from "@marzneshin/features/i18n";
import {
UsersQueryFetchKey,
UserMutationType
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
export async function updateUser(user: UserMutationType): Promise<UserMutationType> {
return fetch(`/users/${user.username}`, { method: 'put', body: user }).then((user) => {
diff --git a/dashboard/src/features/users/services/user-status.mutate.ts b/dashboard/src/modules/users/api/commands/user-status.command.ts
rename from dashboard/src/features/users/services/user-status.mutate.ts
rename to dashboard/src/modules/users/api/commands/user-status.command.ts
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import i18n from "@marzneshin/features/i18n";
import {
UserMutationType,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
interface UsersStatusEnabledQuery {
diff --git a/dashboard/src/features/users/services/user-subscription-revoke.command.ts b/dashboard/src/modules/users/api/commands/user-subscription-revoke.command.ts
rename from dashboard/src/features/users/services/user-subscription-revoke.command.ts
rename to dashboard/src/modules/users/api/commands/user-subscription-revoke.command.ts
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import i18n from "@marzneshin/features/i18n";
import {
UserMutationType
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
export async function userSubscriptionRvoke(user: UserMutationType): Promise<UserMutationType> {
return fetch(`/users/${user.username}/revoke_sub`, { method: 'post', body: user }).then((user) => {
diff --git a/dashboard/src/features/users/services/user-usage-reset.command.ts b/dashboard/src/modules/users/api/commands/user-usage-reset.command.ts
rename from dashboard/src/features/users/services/user-usage-reset.command.ts
rename to dashboard/src/modules/users/api/commands/user-usage-reset.command.ts
@@ -5,7 +5,7 @@ import i18n from "@marzneshin/features/i18n";
import {
UsersQueryFetchKey,
UserMutationType
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
export async function userUsageReset(user: UserMutationType): Promise<UserMutationType> {
return fetch(`/users/${user.username}/reset`, { method: 'post', body: user }).then((user) => {
diff --git a/dashboard/src/modules/users/api/index.ts b/dashboard/src/modules/users/api/index.ts
@@ -0,0 +1,2 @@
+export * from "./queries";
+export * from "./commands";
diff --git a/dashboard/src/modules/users/api/queries/index.ts b/dashboard/src/modules/users/api/queries/index.ts
@@ -0,0 +1,4 @@
+export * from "./user.query"
+export * from "./users.query"
+export * from "./users-stats.query"
+export * from "./user-nodes-usage.query"
diff --git a/dashboard/src/modules/users/api/queries/user-nodes-usage.query.ts b/dashboard/src/modules/users/api/queries/user-nodes-usage.query.ts
@@ -0,0 +1,49 @@
+import { useQuery } from "@tanstack/react-query";
+import { fetch } from "@marzneshin/utils";
+import { UsersQueryFetchKey } from "../..";
+
+type UsageMetric = number[];
+
+interface NodeUsage {
+ node_id: number;
+ node_name: string;
+ usages: Array<UsageMetric>;
+}
+
+export interface UserNodeUsagesResponse {
+ username: string;
+ node_usages: NodeUsage[];
+}
+
+export interface UserNodeUsagesQueryOptions {
+ username: string;
+ start?: string;
+ end?: string
+}
+
+export type UserNodeUsagesQueryKey = [string, string, { start?: string, end?: string }]
+
+export const UserNodeUsagesDefault = {
+ username: "",
+ node_usages: []
+}
+
+export async function fetchUserNodeUsages({ queryKey }: { queryKey: UserNodeUsagesQueryKey }): Promise<UserNodeUsagesResponse> {
+ return await fetch(`/users/${queryKey[1]}/usage`, {
+ query: {
+ start: queryKey[2].start,
+ end: queryKey[2].end
+ }
+ }).then((result) => {
+ return result;
+ });
+}
+
+export const useUserNodeUsagesQuery = ({ username, start, end }: UserNodeUsagesQueryOptions) => {
+ return useQuery({
+ queryKey: [UsersQueryFetchKey, username, { start, end }],
+ queryFn: fetchUserNodeUsages,
+ refetchInterval: 1000 * 60 * 60, // 60min refresh
+ initialData: UserNodeUsagesDefault
+ })
+}
diff --git a/dashboard/src/features/users/services/user.query.ts b/dashboard/src/modules/users/api/queries/user.query.ts
rename from dashboard/src/features/users/services/user.query.ts
rename to dashboard/src/modules/users/api/queries/user.query.ts
@@ -1,6 +1,6 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { fetch } from "@marzneshin/utils";
-import type { UserType } from "../types";
+import type { UserType } from "@marzneshin/modules/users";
export async function fetchUser({
queryKey,
diff --git a/dashboard/src/features/users/services/users-stats.query.ts b/dashboard/src/modules/users/api/queries/users-stats.query.ts
rename from dashboard/src/features/users/services/users-stats.query.ts
rename to dashboard/src/modules/users/api/queries/users-stats.query.ts
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@marzneshin/utils";
-import { UsersQueryFetchKey } from ".";
+import { UsersQueryFetchKey } from "../..";
export interface UsersStatsResponse {
total: number;
diff --git a/dashboard/src/features/users/services/users.query.ts b/dashboard/src/modules/users/api/queries/users.query.ts
rename from dashboard/src/features/users/services/users.query.ts
rename to dashboard/src/modules/users/api/queries/users.query.ts
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@marzneshin/utils";
-import { UserType } from "../types";
+import { UserType } from "@marzneshin/modules/users";
import {
FetchEntityReturn,
UseEntityQueryProps,
diff --git a/dashboard/src/features/users/components/dialogs/delete-confirmation/index.tsx b/dashboard/src/modules/users/components/dialogs/delete-confirmation/index.tsx
rename from dashboard/src/features/users/components/dialogs/delete-confirmation/index.tsx
rename to dashboard/src/modules/users/components/dialogs/delete-confirmation/index.tsx
@@ -3,7 +3,7 @@ import { type FC, useEffect } from "react";
import {
type UserMutationType,
useUsersDeletionMutation,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
interface UsersDeleteConfirmationDialogProps {
onOpenChange: (state: boolean) => void;
diff --git a/dashboard/src/features/users/components/dialogs/index.ts b/dashboard/src/modules/users/components/dialogs/index.ts
rename from dashboard/src/features/users/components/dialogs/index.ts
rename to dashboard/src/modules/users/components/dialogs/index.ts
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/activation-deadline.tsx b/dashboard/src/modules/users/components/dialogs/mutation/fields/activation-deadline.tsx
rename from dashboard/src/features/users/components/dialogs/mutation/fields/activation-deadline.tsx
rename to dashboard/src/modules/users/components/dialogs/mutation/fields/activation-deadline.tsx
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/data-limit-reset-strategy.tsx b/dashboard/src/modules/users/components/dialogs/mutation/fields/data-limit-reset-strategy.tsx
rename from dashboard/src/features/users/components/dialogs/mutation/fields/data-limit-reset-strategy.tsx
rename to dashboard/src/modules/users/components/dialogs/mutation/fields/data-limit-reset-strategy.tsx
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/data-limit.tsx b/dashboard/src/modules/users/components/dialogs/mutation/fields/data-limit.tsx
rename from dashboard/src/features/users/components/dialogs/mutation/fields/data-limit.tsx
rename to dashboard/src/modules/users/components/dialogs/mutation/fields/data-limit.tsx
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/expire-date.tsx b/dashboard/src/modules/users/components/dialogs/mutation/fields/expire-date.tsx
rename from dashboard/src/features/users/components/dialogs/mutation/fields/expire-date.tsx
rename to dashboard/src/modules/users/components/dialogs/mutation/fields/expire-date.tsx
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/index.ts b/dashboard/src/modules/users/components/dialogs/mutation/fields/index.ts
rename from dashboard/src/features/users/components/dialogs/mutation/fields/index.ts
rename to dashboard/src/modules/users/components/dialogs/mutation/fields/index.ts
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/note.tsx b/dashboard/src/modules/users/components/dialogs/mutation/fields/note.tsx
rename from dashboard/src/features/users/components/dialogs/mutation/fields/note.tsx
rename to dashboard/src/modules/users/components/dialogs/mutation/fields/note.tsx
diff --git a/dashboard/src/features/users/components/dialogs/mutation/fields/usage-duration.tsx b/dashboard/src/modules/users/components/dialogs/mutation/fields/usage-duration.tsx
rename from dashboard/src/features/users/components/dialogs/mutation/fields/usage-duration.tsx
rename to dashboard/src/modules/users/components/dialogs/mutation/fields/usage-duration.tsx
diff --git a/dashboard/src/features/users/components/dialogs/mutation/index.tsx b/dashboard/src/modules/users/components/dialogs/mutation/index.tsx
rename from dashboard/src/features/users/components/dialogs/mutation/index.tsx
rename to dashboard/src/modules/users/components/dialogs/mutation/index.tsx
@@ -18,7 +18,7 @@ import {
UserSchema,
UsernameField,
type UserMutationType,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
import { ServicesField } from "@marzneshin/features/services";
import { NoteField } from "./fields";
import { type MutationDialogProps, useMutationDialog } from "@marzneshin/hooks";
diff --git a/dashboard/src/features/users/components/dialogs/mutation/sections/data-limit/index.tsx b/dashboard/src/modules/users/components/dialogs/mutation/sections/data-limit/index.tsx
rename from dashboard/src/features/users/components/dialogs/mutation/sections/data-limit/index.tsx
rename to dashboard/src/modules/users/components/dialogs/mutation/sections/data-limit/index.tsx
diff --git a/dashboard/src/features/users/components/dialogs/mutation/sections/data-limit/use-data-limit.ts b/dashboard/src/modules/users/components/dialogs/mutation/sections/data-limit/use-data-limit.ts
rename from dashboard/src/features/users/components/dialogs/mutation/sections/data-limit/use-data-limit.ts
rename to dashboard/src/modules/users/components/dialogs/mutation/sections/data-limit/use-data-limit.ts
diff --git a/dashboard/src/features/users/components/dialogs/mutation/sections/expiration-method/expiration-method.strategy.ts b/dashboard/src/modules/users/components/dialogs/mutation/sections/expiration-method/expiration-method.strategy.ts
rename from dashboard/src/features/users/components/dialogs/mutation/sections/expiration-method/expiration-method.strategy.ts
rename to dashboard/src/modules/users/components/dialogs/mutation/sections/expiration-method/expiration-method.strategy.ts
@@ -1,5 +1,5 @@
import { useFormContext } from "react-hook-form";
-import { ExpireStrategy } from "@marzneshin/features/users";
+import { ExpireStrategy } from "@marzneshin/modules/users";
abstract class ExpirationMethodStrategy {
protected abstract strategy: string;
diff --git a/dashboard/src/features/users/components/dialogs/mutation/sections/expiration-method/index.tsx b/dashboard/src/modules/users/components/dialogs/mutation/sections/expiration-method/index.tsx
rename from dashboard/src/features/users/components/dialogs/mutation/sections/expiration-method/index.tsx
rename to dashboard/src/modules/users/components/dialogs/mutation/sections/expiration-method/index.tsx
@@ -7,12 +7,12 @@ import {
} from '@marzneshin/components';
import type {
UserMutationType,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
import {
ExpireDateField,
ActivationDeadlineField,
UsageDurationField,
-} from "@marzneshin/features/users/components/dialogs/mutation/fields";
+} from "@marzneshin/modules/users/components/dialogs/mutation/fields";
import { TabsList } from "@radix-ui/react-tabs";
import { useTranslation } from "react-i18next";
import { useExpirationMethodTabs } from "./use-expiration-method-tabs";
diff --git a/dashboard/src/features/users/components/dialogs/mutation/sections/expiration-method/use-expiration-method-tabs.ts b/dashboard/src/modules/users/components/dialogs/mutation/sections/expiration-method/use-expiration-method-tabs.ts
rename from dashboard/src/features/users/components/dialogs/mutation/sections/expiration-method/use-expiration-method-tabs.ts
rename to dashboard/src/modules/users/components/dialogs/mutation/sections/expiration-method/use-expiration-method-tabs.ts
@@ -1,4 +1,4 @@
-import { UserMutationType, ExpireStrategy } from "@marzneshin/features/users";
+import { UserMutationType, ExpireStrategy } from "@marzneshin/modules/users";
import { useState, useEffect } from "react";
import { useFormContext } from "react-hook-form";
import { strategies } from "./expiration-method.strategy"
diff --git a/dashboard/src/features/users/components/dialogs/mutation/sections/index.ts b/dashboard/src/modules/users/components/dialogs/mutation/sections/index.ts
rename from dashboard/src/features/users/components/dialogs/mutation/sections/index.ts
rename to dashboard/src/modules/users/components/dialogs/mutation/sections/index.ts
diff --git a/dashboard/src/features/users/components/dialogs/settings/index.tsx b/dashboard/src/modules/users/components/dialogs/settings/index.tsx
rename from dashboard/src/features/users/components/dialogs/settings/index.tsx
rename to dashboard/src/modules/users/components/dialogs/settings/index.tsx
@@ -11,8 +11,9 @@ import {
} from "@marzneshin/components";
import {
UserServicesTable,
+ UserNodesUsageWidget,
type UserType,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -53,6 +54,9 @@ export const UsersSettingsDialog: FC<UsersSettingsDialogProps> = ({
<TabsTrigger className="w-full" value="subscription">
{t("subscription")}
</TabsTrigger>
+ <TabsTrigger className="w-full" value="nodes-usage">
+ {t("page.users.nodes-usage")}
+ </TabsTrigger>
</TabsList>
<TabsContent
value="info"
@@ -67,6 +71,9 @@ export const UsersSettingsDialog: FC<UsersSettingsDialogProps> = ({
<QRCodeSection entity={entity} />
<SubscriptionActions entity={entity} />
</TabsContent>
+ <TabsContent value="nodes-usage">
+ <UserNodesUsageWidget user={entity} />
+ </TabsContent>
</Tabs>
</ScrollArea>
) : (
diff --git a/dashboard/src/features/users/components/dialogs/settings/user-info/index.ts b/dashboard/src/modules/users/components/dialogs/settings/user-info/index.ts
rename from dashboard/src/features/users/components/dialogs/settings/user-info/index.ts
rename to dashboard/src/modules/users/components/dialogs/settings/user-info/index.ts
diff --git a/dashboard/src/features/users/components/dialogs/settings/user-info/qrcode.tsx b/dashboard/src/modules/users/components/dialogs/settings/user-info/qrcode.tsx
rename from dashboard/src/features/users/components/dialogs/settings/user-info/qrcode.tsx
rename to dashboard/src/modules/users/components/dialogs/settings/user-info/qrcode.tsx
@@ -1,4 +1,4 @@
-import type { UserType } from "@marzneshin/features/users";
+import type { UserType } from "@marzneshin/modules/users";
import QRCode from "react-qr-code";
import { QrCodeIcon } from "lucide-react";
import type { FC } from "react";
diff --git a/dashboard/src/features/users/components/dialogs/settings/user-info/subscription-actions.tsx b/dashboard/src/modules/users/components/dialogs/settings/user-info/subscription-actions.tsx
rename from dashboard/src/features/users/components/dialogs/settings/user-info/subscription-actions.tsx
rename to dashboard/src/modules/users/components/dialogs/settings/user-info/subscription-actions.tsx
@@ -1,7 +1,7 @@
import { Button, HStack } from "@marzneshin/components";
import { useTranslation } from "react-i18next";
import { getSubscriptionLink } from "@marzneshin/utils";
-import { useUserSubscriptionRevokeCmd } from "@marzneshin/features/users";
+import { useUserSubscriptionRevokeCmd } from "@marzneshin/modules/users";
import { CopyToClipboardButton } from "@marzneshin/components";
import { SquareCode } from "lucide-react";
import type { QRCodeProps } from "./qrcode";
diff --git a/dashboard/src/features/users/components/dialogs/settings/user-info/table.tsx b/dashboard/src/modules/users/components/dialogs/settings/user-info/table.tsx
rename from dashboard/src/features/users/components/dialogs/settings/user-info/table.tsx
rename to dashboard/src/modules/users/components/dialogs/settings/user-info/table.tsx
@@ -20,7 +20,7 @@ import {
useUserUsageResetCmd,
UserDataLimitReachedPill,
UserExpiredPill,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
import { useTranslation } from "react-i18next";
import { formatDistanceToNow } from "date-fns";
import { format } from '@chbphone55/pretty-bytes';
diff --git a/dashboard/src/features/users/components/dialogs/settings/user-info/user-status-enable-button.tsx b/dashboard/src/modules/users/components/dialogs/settings/user-info/user-status-enable-button.tsx
rename from dashboard/src/features/users/components/dialogs/settings/user-info/user-status-enable-button.tsx
rename to dashboard/src/modules/users/components/dialogs/settings/user-info/user-status-enable-button.tsx
@@ -1,6 +1,6 @@
import { Button } from "@marzneshin/components";
import { cn } from "@marzneshin/utils";
-import { UserType, useUserStatusEnable } from "@marzneshin/features/users";
+import { UserType, useUserStatusEnable } from "@marzneshin/modules/users";
import { LoaderIcon, UserCheck, UserX } from "lucide-react";
import { FC, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
diff --git a/dashboard/src/features/users/components/form-fields/index.ts b/dashboard/src/modules/users/components/form-fields/index.ts
rename from dashboard/src/features/users/components/form-fields/index.ts
rename to dashboard/src/modules/users/components/form-fields/index.ts
diff --git a/dashboard/src/features/users/components/form-fields/username.tsx b/dashboard/src/modules/users/components/form-fields/username.tsx
rename from dashboard/src/features/users/components/form-fields/username.tsx
rename to dashboard/src/modules/users/components/form-fields/username.tsx
diff --git a/dashboard/src/features/users/components/index.ts b/dashboard/src/modules/users/components/index.ts
rename from dashboard/src/features/users/components/index.ts
rename to dashboard/src/modules/users/components/index.ts
diff --git a/dashboard/src/features/users/components/no-service-alert/index.tsx b/dashboard/src/modules/users/components/no-service-alert/index.tsx
rename from dashboard/src/features/users/components/no-service-alert/index.tsx
rename to dashboard/src/modules/users/components/no-service-alert/index.tsx
diff --git a/dashboard/src/features/users/components/online-status.tsx b/dashboard/src/modules/users/components/online-status.tsx
rename from dashboard/src/features/users/components/online-status.tsx
rename to dashboard/src/modules/users/components/online-status.tsx
@@ -1,7 +1,7 @@
import { FC } from "react"
import { useTranslation } from "react-i18next"
-import { cn, isUserOnline } from "@marzneshin/utils"
-import { UserType } from ".."
+import { cn } from "@marzneshin/utils"
+import { UserType, isUserOnline } from "@marzneshin/modules/users"
import { Tooltip, TooltipContent, TooltipTrigger } from "@marzneshin/components"
import { Circle } from "lucide-react"
diff --git a/dashboard/src/features/users/components/pills/index.ts b/dashboard/src/modules/users/components/pills/index.ts
rename from dashboard/src/features/users/components/pills/index.ts
rename to dashboard/src/modules/users/components/pills/index.ts
diff --git a/dashboard/src/features/users/components/pills/user-activated-pill.tsx b/dashboard/src/modules/users/components/pills/user-activated-pill.tsx
rename from dashboard/src/features/users/components/pills/user-activated-pill.tsx
rename to dashboard/src/modules/users/components/pills/user-activated-pill.tsx
@@ -1,7 +1,7 @@
import { type FC } from "react";
import { BooleanPill } from "@marzneshin/components";
import { useTranslation } from "react-i18next";
-import { UserProp } from "@marzneshin/features/users";
+import { UserProp } from "@marzneshin/modules/users";
export const UserActivatedPill: FC<UserProp> = ({ user }) => {
const { t } = useTranslation();
diff --git a/dashboard/src/features/users/components/pills/user-data-limit-reached-pill.tsx b/dashboard/src/modules/users/components/pills/user-data-limit-reached-pill.tsx
rename from dashboard/src/features/users/components/pills/user-data-limit-reached-pill.tsx
rename to dashboard/src/modules/users/components/pills/user-data-limit-reached-pill.tsx
@@ -1,7 +1,7 @@
import { type FC } from "react";
import { BooleanPill } from "@marzneshin/components";
import { useTranslation } from "react-i18next";
-import { UserProp } from "@marzneshin/features/users";
+import { UserProp } from "@marzneshin/modules/users";
export const UserDataLimitReachedPill: FC<UserProp> = ({ user }) => {
const { t } = useTranslation();
diff --git a/dashboard/src/features/users/components/pills/user-enabled-pill.tsx b/dashboard/src/modules/users/components/pills/user-enabled-pill.tsx
rename from dashboard/src/features/users/components/pills/user-enabled-pill.tsx
rename to dashboard/src/modules/users/components/pills/user-enabled-pill.tsx
@@ -1,7 +1,7 @@
import { type FC } from "react";
import { BooleanPill } from "@marzneshin/components";
import { useTranslation } from "react-i18next";
-import { UserProp } from "@marzneshin/features/users";
+import { UserProp } from "@marzneshin/modules/users";
export const UserEnabledPill: FC<UserProp> = ({ user }) => {
const { t } = useTranslation();
diff --git a/dashboard/src/features/users/components/pills/user-expire-strategy-pill.tsx b/dashboard/src/modules/users/components/pills/user-expire-strategy-pill.tsx
rename from dashboard/src/features/users/components/pills/user-expire-strategy-pill.tsx
rename to dashboard/src/modules/users/components/pills/user-expire-strategy-pill.tsx
@@ -2,7 +2,7 @@
import { type FC } from "react";
import { Badge } from "@marzneshin/components";
import { useTranslation } from "react-i18next";
-import { UserProp } from "@marzneshin/features/users";
+import { UserProp } from "@marzneshin/modules/users";
export const UserExpireStrategyPill: FC<UserProp> = ({ user }) => {
const { t } = useTranslation();
diff --git a/dashboard/src/features/users/components/pills/user-expired-pill.tsx b/dashboard/src/modules/users/components/pills/user-expired-pill.tsx
rename from dashboard/src/features/users/components/pills/user-expired-pill.tsx
rename to dashboard/src/modules/users/components/pills/user-expired-pill.tsx
@@ -1,7 +1,7 @@
import { type FC } from "react";
import { BooleanPill } from "@marzneshin/components";
import { useTranslation } from "react-i18next";
-import { UserProp } from "@marzneshin/features/users";
+import { UserProp } from "@marzneshin/modules/users";
export const UserExpiredPill: FC<UserProp> = ({ user }) => {
const { t } = useTranslation();
diff --git a/dashboard/src/features/users/components/services-card.tsx b/dashboard/src/modules/users/components/services-card.tsx
rename from dashboard/src/features/users/components/services-card.tsx
rename to dashboard/src/modules/users/components/services-card.tsx
diff --git a/dashboard/src/features/users/components/tables/index.ts b/dashboard/src/modules/users/components/tables/index.ts
rename from dashboard/src/features/users/components/tables/index.ts
rename to dashboard/src/modules/users/components/tables/index.ts
diff --git a/dashboard/src/features/users/components/tables/services/columns.tsx b/dashboard/src/modules/users/components/tables/services/columns.tsx
rename from dashboard/src/features/users/components/tables/services/columns.tsx
rename to dashboard/src/modules/users/components/tables/services/columns.tsx
diff --git a/dashboard/src/features/users/components/tables/services/index.tsx b/dashboard/src/modules/users/components/tables/services/index.tsx
rename from dashboard/src/features/users/components/tables/services/index.tsx
rename to dashboard/src/modules/users/components/tables/services/index.tsx
@@ -1,13 +1,14 @@
-import { type FC, useCallback, useState } from "react";
+
import { Button } from "@marzneshin/components";
import { SelectableEntityTable, useRowSelection } from "@marzneshin/features/entity-table";
import { columns } from "./columns";
import {
type UserType,
useUsersUpdateMutation,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
import { useTranslation } from "react-i18next";
import { fetchUserServices } from "@marzneshin/features/services";
+import { useState, useCallback, FC } from "react";
interface UserServicesTableProps {
user: UserType;
diff --git a/dashboard/src/features/users/components/tables/users/columns.tsx b/dashboard/src/modules/users/components/tables/users/columns.tsx
rename from dashboard/src/features/users/components/tables/users/columns.tsx
rename to dashboard/src/modules/users/components/tables/users/columns.tsx
@@ -5,7 +5,7 @@ import {
UserActivatedPill,
UserExpireStrategyPill,
UserExpirationValue
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
import { useAdminsQuery } from "@marzneshin/modules/admins";
import i18n from "@marzneshin/features/i18n";
import {
diff --git a/dashboard/src/features/users/components/tables/users/index.tsx b/dashboard/src/modules/users/components/tables/users/index.tsx
rename from dashboard/src/features/users/components/tables/users/index.tsx
rename to dashboard/src/modules/users/components/tables/users/index.tsx
@@ -1,5 +1,5 @@
import type { FC } from "react";
-import { fetchUsers, UserType, UsersQueryFetchKey } from "@marzneshin/features/users";
+import { fetchUsers, UserType, UsersQueryFetchKey } from "@marzneshin/modules/users";
import { columns as columnsFn } from "./columns";
import { EntityTable } from "@marzneshin/features/entity-table";
import { useAuth } from "@marzneshin/features/auth";
diff --git a/dashboard/src/features/users/components/used-user-traffic.tsx b/dashboard/src/modules/users/components/used-user-traffic.tsx
rename from dashboard/src/features/users/components/used-user-traffic.tsx
rename to dashboard/src/modules/users/components/used-user-traffic.tsx
@@ -1,5 +1,5 @@
import { FC } from 'react'
-import { UserType } from '@marzneshin/features/users';
+import { UserType } from '@marzneshin/modules/users';
import { CircularProgress } from '@nextui-org/progress';
import { format } from '@chbphone55/pretty-bytes';
import { useTranslation } from 'react-i18next';
diff --git a/dashboard/src/features/users/components/user-expiration-value.tsx b/dashboard/src/modules/users/components/user-expiration-value.tsx
rename from dashboard/src/features/users/components/user-expiration-value.tsx
rename to dashboard/src/modules/users/components/user-expiration-value.tsx
@@ -1,6 +1,6 @@
import { format } from "date-fns";
import { type FC } from 'react'
-import { UserProp } from "@marzneshin/features/users";
+import { UserProp } from "@marzneshin/modules/users";
import { useTranslation } from "react-i18next";
export const UserExpirationValue: FC<UserProp> = ({ user }) => {
diff --git a/dashboard/src/features/users/components/users-status.tsx b/dashboard/src/modules/users/components/users-status.tsx
rename from dashboard/src/features/users/components/users-status.tsx
rename to dashboard/src/modules/users/components/users-status.tsx
diff --git a/dashboard/src/modules/users/components/widgets/index.ts b/dashboard/src/modules/users/components/widgets/index.ts
@@ -0,0 +1,2 @@
+export * from './users-stats-widget'
+export * from './user-nodes-usage-widget'
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
diff --git a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/hooks/index.ts b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from "./use-chart-config"
+export * from "./use-transform-data"
+export * from "./use-from-now-interval"
diff --git a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/hooks/use-chart-config.ts b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/hooks/use-chart-config.ts
@@ -0,0 +1,25 @@
+import {
+ ChartConfig,
+} from "@marzneshin/components";
+import { UserNodeUsagesResponse } from "@marzneshin/modules/users";
+import { interpolateColors } from "@marzneshin/utils";
+import { interpolateRdBu } from "d3";
+
+export const useChartConfig = (nodesUsage: UserNodeUsagesResponse) => {
+ const numberOfNodes = nodesUsage.node_usages.length;
+ const config: Record<string, any> = {
+ views: {
+ label: "Page Views",
+ }
+ }
+ const colorRangeInfo = {
+ colorStart: 0,
+ colorEnd: 1,
+ useEndAsStart: false,
+ };
+ const colors = interpolateColors(numberOfNodes, interpolateRdBu, colorRangeInfo);
+ nodesUsage.node_usages.forEach((node, i) => {
+ config[node.node_name] = { label: node.node_name, color: colors[i] };
+ })
+ return config satisfies ChartConfig;
+}
diff --git a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/hooks/use-from-now-interval.ts b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/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/modules/users/components/widgets/user-nodes-usage-widget/hooks/use-transform-data.ts b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/hooks/use-transform-data.ts
@@ -0,0 +1,31 @@
+import { UserNodeUsagesResponse } from "@marzneshin/modules/users";
+
+export type ChartDataEntry = {
+ datetime: string;
+ [key: string]: number | string;
+};
+
+export type ChartData = ChartDataEntry[];
+
+export function useTransformData(initialData: UserNodeUsagesResponse): ChartData {
+ const chartData: ChartData = [];
+ const nodeDataMap: { [date: string]: ChartDataEntry } = {};
+
+ initialData.node_usages.forEach(node => {
+ node.usages.forEach(usageData => {
+ const [timestamp, usage] = usageData;
+ const date = new Date(timestamp * 1000);
+ const formattedDate = date.toISOString();
+
+ if (!nodeDataMap[formattedDate]) {
+ nodeDataMap[formattedDate] = { datetime: formattedDate };
+ }
+ nodeDataMap[formattedDate][node.node_name] = usage;
+ });
+ });
+
+ for (const date in nodeDataMap) {
+ chartData.push(nodeDataMap[date]);
+ }
+ return chartData;
+}
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
@@ -0,0 +1,147 @@
+import { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ SectionWidget,
+ ChartLegendContent,
+ ChartContainer,
+ Awaiting,
+ ChartLegend,
+ ChartTooltip,
+ ChartTooltipContent,
+} 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 { format as formatByte } from '@chbphone55/pretty-bytes';
+import { useUserNodeUsagesQuery } from "@marzneshin/modules/users";
+import { UsageGraphSkeleton } from "./skeleton"
+
+export const UserNodesUsageWidget: FC<UserNodesUsageWidgetProps> = ({
+ user,
+}) => {
+ const { t } = useTranslation();
+ const [timeRange, setTimeRange] = useState("1d")
+ const { start, end } = useFromNowInterval(timeRange as ChartDateInterval);
+ const { data, isPending } = useUserNodeUsagesQuery({ username: user.username, start, end })
+ const chartData = useTransformData(data);
+ const config = useChartConfig(data);
+
+ return (
+ <Awaiting
+ Component={
+ <SectionWidget
+ 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}>
+ <AreaChart
+ accessibilityLayer
+ data={chartData}
+ margin={{
+ left: 13,
+ 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) => {
+ 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);
+ }}
+ />
+ <ChartTooltip
+ cursor={false}
+ 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}`
+ }}
+ />
+ }
+ />
+ <defs>
+ {data.node_usages.map(node =>
+ <linearGradient key={node.node_name} id={node.node_name} x1="0" y1="0" x2="0" y2="1">
+ <stop
+ offset="5%"
+ stopColor={config[node.node_name].color}
+ stopOpacity={0.1}
+ />
+ <stop
+ offset="95%"
+ stopColor={config[node.node_name].color}
+ stopOpacity={0.8}
+ />
+ </linearGradient>
+ )}
+ </defs>
+ {data.node_usages.map(node =>
+ <Area
+ dataKey={node.node_name}
+ key={node.node_name}
+ type="natural"
+ fill={config[node.node_name].color}
+ fillOpacity={0.4}
+ stackId={node.node_id}
+ stroke={config[node.node_name].color}
+ />
+ )}
+ <ChartLegend content={<ChartLegendContent />} />
+ </AreaChart>
+ </ChartContainer>
+ </SectionWidget>
+ }
+ Skeleton={<UsageGraphSkeleton />}
+ isFetching={isPending}
+ />
+ );
+};
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
@@ -0,0 +1,409 @@
+{
+ "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/user-nodes-usage-widget/select-date-view.tsx b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/select-date-view.tsx
@@ -0,0 +1,40 @@
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@marzneshin/components";
+import { useTranslation } from 'react-i18next';
+
+export const SelectDateView = (
+ { timeRange, setTimeRange }: { timeRange: string, setTimeRange: (s: string) => void }
+) => {
+ const { t } = useTranslation();
+
+ return (
+ <Select value={timeRange} onValueChange={setTimeRange}>
+ <SelectTrigger
+ className="w-[160px] rounded-lg sm:ml-auto"
+ aria-label="Select a value"
+ >
+ <SelectValue placeholder="Last 3 months" />
+ </SelectTrigger>
+ <SelectContent className="rounded-xl">
+ <SelectItem value="1d" className="rounded-lg">
+ {t('page.users.settings.nodes-usage.last-hours', { count: 24 })}
+ </SelectItem>
+ <SelectItem value="7d" className="rounded-lg">
+ {t('page.users.settings.nodes-usage.last-days', { count: 7 })}
+ </SelectItem>
+ <SelectItem value="30d" className="rounded-lg">
+ {t('page.users.settings.nodes-usage.last-days', { count: 30 })}
+ </SelectItem>
+ <SelectItem value="90d" className="rounded-lg">
+ {t('page.users.settings.nodes-usage.last-months', { count: 3 })}
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ )
+};
+
diff --git a/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/skeleton.tsx b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/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/modules/users/components/widgets/user-nodes-usage-widget/types.ts b/dashboard/src/modules/users/components/widgets/user-nodes-usage-widget/types.ts
@@ -0,0 +1,12 @@
+import { UserType } from "@marzneshin/modules/users";
+
+export interface NodesUsage {
+ datetime: Date;
+ nodes: Record<string, number>;
+}
+
+export interface UserNodesUsageWidgetProps {
+ user: UserType;
+}
+
+export type ChartDateInterval = '90d' | '30d' | '7d' | '1d';
diff --git a/dashboard/src/features/users/components/widgets/users-stats-widget/index.tsx b/dashboard/src/modules/users/components/widgets/users-stats-widget/index.tsx
rename from dashboard/src/features/users/components/widgets/users-stats-widget/index.tsx
rename to dashboard/src/modules/users/components/widgets/users-stats-widget/index.tsx
diff --git a/dashboard/src/features/users/components/widgets/users-stats-widget/legend.tsx b/dashboard/src/modules/users/components/widgets/users-stats-widget/legend.tsx
rename from dashboard/src/features/users/components/widgets/users-stats-widget/legend.tsx
rename to dashboard/src/modules/users/components/widgets/users-stats-widget/legend.tsx
diff --git a/dashboard/src/features/users/components/widgets/users-stats-widget/users-stats-widget.stories.ts b/dashboard/src/modules/users/components/widgets/users-stats-widget/users-stats-widget.stories.ts
rename from dashboard/src/features/users/components/widgets/users-stats-widget/users-stats-widget.stories.ts
rename to dashboard/src/modules/users/components/widgets/users-stats-widget/users-stats-widget.stories.ts
diff --git a/dashboard/src/features/users/constants.ts b/dashboard/src/modules/users/constants.ts
rename from dashboard/src/features/users/constants.ts
rename to dashboard/src/modules/users/constants.ts
diff --git a/dashboard/src/features/users/contexts/router.ts b/dashboard/src/modules/users/contexts/router.ts
rename from dashboard/src/features/users/contexts/router.ts
rename to dashboard/src/modules/users/contexts/router.ts
@@ -1,5 +1,5 @@
import { createContext, useContext } from "react";
-import { UserType } from "@marzneshin/features/users";
+import { UserType } from "@marzneshin/modules/users";
interface RouterUserContextProps {
user: UserType | null;
diff --git a/dashboard/src/features/users/types/data-limit-reset-strategy.ts b/dashboard/src/modules/users/domain/data-limit-reset-strategy.ts
rename from dashboard/src/features/users/types/data-limit-reset-strategy.ts
rename to dashboard/src/modules/users/domain/data-limit-reset-strategy.ts
diff --git a/dashboard/src/features/users/types/expire-strategy.ts b/dashboard/src/modules/users/domain/expire-strategy.ts
rename from dashboard/src/features/users/types/expire-strategy.ts
rename to dashboard/src/modules/users/domain/expire-strategy.ts
diff --git a/dashboard/src/features/users/types/index.ts b/dashboard/src/modules/users/domain/index.ts
rename from dashboard/src/features/users/types/index.ts
rename to dashboard/src/modules/users/domain/index.ts
@@ -1,3 +1,5 @@
+export * from "./user-traffic-sorting-fn"
+export * from "./is-online"
export * from "./user"
export * from "./user-prop"
export * from "./schema"
diff --git a/dashboard/src/utils/is-online.ts b/dashboard/src/modules/users/domain/is-online.ts
rename from dashboard/src/utils/is-online.ts
rename to dashboard/src/modules/users/domain/is-online.ts
@@ -1,4 +1,4 @@
-import { UserType } from "@marzneshin/features/users";
+import { UserType } from "@marzneshin/modules/users";
/**
* Checks whether the user has been online recently.
diff --git a/dashboard/src/features/users/types/schema.ts b/dashboard/src/modules/users/domain/schema.ts
rename from dashboard/src/features/users/types/schema.ts
rename to dashboard/src/modules/users/domain/schema.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import { DATA_LIMIT_METRIC } from '@marzneshin/features/users';
+import { DATA_LIMIT_METRIC } from '@marzneshin/modules/users';
export const UserSchema = z.object({
username: z.string().min(1, { message: 'Username is required' }),
diff --git a/dashboard/src/modules/users/domain/user-prop.ts b/dashboard/src/modules/users/domain/user-prop.ts
@@ -0,0 +1,5 @@
+import { UserType } from "@marzneshin/modules/users";
+
+export interface UserProp {
+ user: UserType;
+}
diff --git a/dashboard/src/features/users/domain/user-traffic-sorting-fn.ts b/dashboard/src/modules/users/domain/user-traffic-sorting-fn.ts
rename from dashboard/src/features/users/domain/user-traffic-sorting-fn.ts
rename to dashboard/src/modules/users/domain/user-traffic-sorting-fn.ts
@@ -1,4 +1,4 @@
-import { UserType } from "@marzneshin/features/users";
+import { UserType } from "@marzneshin/modules/users";
export const userTrafficSortingFn = (userA: UserType, userB: UserType) => {
if (userA.data_limit && userB.data_limit) {
diff --git a/dashboard/src/features/users/types/user.ts b/dashboard/src/modules/users/domain/user.ts
rename from dashboard/src/features/users/types/user.ts
rename to dashboard/src/modules/users/domain/user.ts
diff --git a/dashboard/src/features/users/index.ts b/dashboard/src/modules/users/index.ts
rename from dashboard/src/features/users/index.ts
rename to dashboard/src/modules/users/index.ts
@@ -1,5 +1,4 @@
-export * from './services'
-export * from './types'
+export * from './api'
export * from './components'
export * from './constants'
export * from './contexts/router'
diff --git a/dashboard/src/routes/_dashboard/index.lazy.tsx b/dashboard/src/routes/_dashboard/index.lazy.tsx
@@ -6,7 +6,7 @@ import {
CardTitle,
Page,
} from '@marzneshin/components';
-import { UsersStatsWidget, useUsersStatsQuery } from '@marzneshin/features/users';
+import { UsersStatsWidget, useUsersStatsQuery } from '@marzneshin/modules/users';
import { useTranslation } from 'react-i18next';
import { FC } from 'react';
diff --git a/dashboard/src/routes/_dashboard/users.lazy.tsx b/dashboard/src/routes/_dashboard/users.lazy.tsx
@@ -7,7 +7,7 @@ import {
Page,
Loading,
} from '@marzneshin/components';
-import { UsersNoServiceAlert, UsersTable } from '@marzneshin/features/users';
+import { UsersNoServiceAlert, UsersTable } from '@marzneshin/modules/users';
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { type FC, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/dashboard/src/routes/_dashboard/users/$userId.tsx b/dashboard/src/routes/_dashboard/users/$userId.tsx
@@ -7,7 +7,7 @@ import { queryClient } from "@marzneshin/utils";
import {
RouterUserContext,
userQueryOptions,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
import { Suspense, useMemo } from "react";
import {
AlertDialog,
diff --git a/dashboard/src/routes/_dashboard/users/$userId/delete.tsx b/dashboard/src/routes/_dashboard/users/$userId/delete.tsx
@@ -5,7 +5,7 @@ import {
import {
UsersDeleteConfirmationDialog,
useRouterUserContext,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
import { useDialog } from "@marzneshin/hooks";
const UserDelete = () => {
diff --git a/dashboard/src/routes/_dashboard/users/$userId/edit.tsx b/dashboard/src/routes/_dashboard/users/$userId/edit.tsx
@@ -5,7 +5,7 @@ import {
import {
UsersMutationDialog,
useRouterUserContext,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
const UserEdit = () => {
diff --git a/dashboard/src/routes/_dashboard/users/$userId/index.tsx b/dashboard/src/routes/_dashboard/users/$userId/index.tsx
@@ -5,7 +5,7 @@ import {
import {
useRouterUserContext,
UsersSettingsDialog,
-} from "@marzneshin/features/users";
+} from "@marzneshin/modules/users";
import { useDialog } from "@marzneshin/hooks";
const UserOpen = () => {
diff --git a/dashboard/src/routes/_dashboard/users/create.tsx b/dashboard/src/routes/_dashboard/users/create.tsx
@@ -1,5 +1,5 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { UsersMutationDialog } from "@marzneshin/features/users";
+import { UsersMutationDialog } from "@marzneshin/modules/users";
const UserCreate = () => {
const navigate = useNavigate({ from: "/users/create" });
diff --git a/dashboard/src/utils/color-generator.ts b/dashboard/src/utils/color-generator.ts
@@ -0,0 +1,43 @@
+/**
+ * @module Color range generator
+ *
+ */
+
+function calculatePoint(i: number, intervalSize: number, colorRangeInfo: ColorRange) {
+ const { colorStart, colorEnd, useEndAsStart } = colorRangeInfo;
+ return (useEndAsStart
+ ? (colorEnd - (i * intervalSize))
+ : (colorStart + (i * intervalSize)));
+}
+
+/* Must use an interpolated color scale, which has a range of [0, 1] */
+interface ColorRange {
+ colorStart: number;
+ colorEnd: number;
+ useEndAsStart: boolean;
+}
+
+/**
+ * @param dataLength - Number of colors to be generated
+ * @param colorScale - The D3 interpolate color scale
+ * @param colorRangeInfo - Color range spectrum
+ * @returns An array of colors within the choosen spectrum
+ *
+ * @description
+ * - Must use an interpolated color scale, which has a range of [0, 1].
+ * - D3 Interpolators are interpolateInfero, interpolateCool, interpolateRainbow, interpolateSpectral, interpolateRdYlBu
+ */
+export function interpolateColors(dataLength: number, colorScale: (c: number) => string, colorRangeInfo: ColorRange) {
+ const { colorStart, colorEnd } = colorRangeInfo;
+ const colorRange = colorEnd - colorStart;
+ const intervalSize = colorRange / dataLength;
+ let i, colorPoint;
+ const colorArray = [];
+
+ for (i = 0; i < dataLength; i++) {
+ colorPoint = calculatePoint(i, intervalSize, colorRangeInfo);
+ colorArray.push(colorScale(colorPoint));
+ }
+
+ return colorArray;
+}
diff --git a/dashboard/src/utils/index.ts b/dashboard/src/utils/index.ts
@@ -3,6 +3,6 @@ export * from './fetch'
export * from './cn'
export * from './query-client'
export * from './subscription-link'
-export * from './is-online'
export * from './project-info'
export * from './compose-refs'
+export * from './color-generator';