feat(users): Integrate real-time online status and enhance UI
This commit is contained in:
@ -40,7 +40,7 @@ class Command(Enum):
|
|||||||
EXTRA_CONFIG_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'extra_config.py')
|
EXTRA_CONFIG_SCRIPT = os.path.join(SCRIPT_DIR, 'hysteria2', 'extra_config.py')
|
||||||
TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module)
|
TRAFFIC_STATUS = 'traffic.py' # won't be called directly (it's a python module)
|
||||||
UPDATE_GEO = os.path.join(SCRIPT_DIR, 'hysteria2', 'update_geo.py')
|
UPDATE_GEO = os.path.join(SCRIPT_DIR, 'hysteria2', 'update_geo.py')
|
||||||
LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.sh')
|
LIST_USERS = os.path.join(SCRIPT_DIR, 'hysteria2', 'list_users.py')
|
||||||
SERVER_INFO = os.path.join(SCRIPT_DIR, 'hysteria2', 'server_info.py')
|
SERVER_INFO = os.path.join(SCRIPT_DIR, 'hysteria2', 'server_info.py')
|
||||||
BACKUP_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'backup.py')
|
BACKUP_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'backup.py')
|
||||||
RESTORE_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'restore.py')
|
RESTORE_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'restore.py')
|
||||||
@ -256,7 +256,7 @@ def list_users() -> dict[str, dict[str, Any]] | None:
|
|||||||
'''
|
'''
|
||||||
Lists all users.
|
Lists all users.
|
||||||
'''
|
'''
|
||||||
if res := run_cmd(['bash', Command.LIST_USERS.value]):
|
if res := run_cmd(['python3', Command.LIST_USERS.value]):
|
||||||
return json.loads(res)
|
return json.loads(res)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
55
core/scripts/hysteria2/list_users.py
Normal file
55
core/scripts/hysteria2/list_users.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hysteria2_api import Hysteria2Client
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Error: hysteria2_api library not found. Please install it.")
|
||||||
|
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
from paths import USERS_FILE, CONFIG_FILE, API_BASE_URL
|
||||||
|
|
||||||
|
def get_secret() -> str | None:
|
||||||
|
if not CONFIG_FILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with CONFIG_FILE.open('r') as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
return config_data.get("trafficStats", {}).get("secret")
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_users() -> dict:
|
||||||
|
if not USERS_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with USERS_FILE.open('r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
users_dict = get_users()
|
||||||
|
secret = get_secret()
|
||||||
|
|
||||||
|
if secret and users_dict:
|
||||||
|
try:
|
||||||
|
client = Hysteria2Client(base_url=API_BASE_URL, secret=secret)
|
||||||
|
online_clients = client.get_online_clients()
|
||||||
|
|
||||||
|
for username, status in online_clients.items():
|
||||||
|
if status.is_online and username in users_dict:
|
||||||
|
users_dict[username]['online_count'] = status.connections
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
users_list = [
|
||||||
|
{**user_data, 'username': username, 'online_count': user_data.get('online_count', 0)}
|
||||||
|
for username, user_data in users_dict.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
print(json.dumps(users_list, indent=2))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -4,19 +4,21 @@ from pydantic import BaseModel, RootModel, Field, field_validator
|
|||||||
|
|
||||||
|
|
||||||
class UserInfoResponse(BaseModel):
|
class UserInfoResponse(BaseModel):
|
||||||
|
username: str
|
||||||
password: str
|
password: str
|
||||||
max_download_bytes: int
|
max_download_bytes: int
|
||||||
expiration_days: int
|
expiration_days: int
|
||||||
account_creation_date: str
|
account_creation_date: Optional[str] = None
|
||||||
blocked: bool
|
blocked: bool
|
||||||
unlimited_ip: bool = Field(False, alias='unlimited_user')
|
unlimited_ip: bool = Field(False, alias='unlimited_user')
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
upload_bytes: Optional[int] = None
|
upload_bytes: Optional[int] = None
|
||||||
download_bytes: Optional[int] = None
|
download_bytes: Optional[int] = None
|
||||||
|
online_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class UserListResponse(RootModel):
|
class UserListResponse(RootModel):
|
||||||
root: dict[str, UserInfoResponse]
|
root: List[UserInfoResponse]
|
||||||
|
|
||||||
class UsernamesRequest(BaseModel):
|
class UsernamesRequest(BaseModel):
|
||||||
usernames: List[str]
|
usernames: List[str]
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
@ -13,10 +12,8 @@ router = APIRouter()
|
|||||||
@router.get('/')
|
@router.get('/')
|
||||||
async def users(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
async def users(request: Request, templates: Jinja2Templates = Depends(get_templates)):
|
||||||
try:
|
try:
|
||||||
dict_users = cli_api.list_users() # type: ignore
|
users_list = cli_api.list_users() or []
|
||||||
users: list[User] = []
|
users: list[User] = [User.from_dict(user_data.get('username', ''), user_data) for user_data in users_list]
|
||||||
if dict_users:
|
|
||||||
users: list[User] = [User.from_dict(key, value) for key, value in dict_users.items()] # type: ignore
|
|
||||||
|
|
||||||
return templates.TemplateResponse('users.html', {'users': users, 'request': request})
|
return templates.TemplateResponse('users.html', {'users': users, 'request': request})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -11,6 +11,7 @@ class User(BaseModel):
|
|||||||
expiry_days: str
|
expiry_days: str
|
||||||
enable: bool
|
enable: bool
|
||||||
unlimited_ip: bool
|
unlimited_ip: bool
|
||||||
|
online_count: int = 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(username: str, user_data: dict):
|
def from_dict(username: str, user_data: dict):
|
||||||
@ -36,7 +37,8 @@ class User(BaseModel):
|
|||||||
'expiry_date': 'N/A',
|
'expiry_date': 'N/A',
|
||||||
'expiry_days': 'N/A',
|
'expiry_days': 'N/A',
|
||||||
'enable': False,
|
'enable': False,
|
||||||
'unlimited_ip': False
|
'unlimited_ip': False,
|
||||||
|
'online_count': 0
|
||||||
}
|
}
|
||||||
|
|
||||||
expiration_days = user_data.get('expiration_days', 0)
|
expiration_days = user_data.get('expiration_days', 0)
|
||||||
@ -77,7 +79,8 @@ class User(BaseModel):
|
|||||||
'expiry_date': display_expiry_date,
|
'expiry_date': display_expiry_date,
|
||||||
'expiry_days': display_expiry_days,
|
'expiry_days': display_expiry_days,
|
||||||
'enable': not user_data.get('blocked', False),
|
'enable': not user_data.get('blocked', False),
|
||||||
'unlimited_ip': user_data.get('unlimited_user', False)
|
'unlimited_ip': user_data.get('unlimited_user', False),
|
||||||
|
'online_count': user_data.get('online_count', 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -93,35 +93,38 @@
|
|||||||
{% for user in users|sort(attribute='username', case_sensitive=false) %}
|
{% for user in users|sort(attribute='username', case_sensitive=false) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="user-checkbox" value="{{ user['username'] }}">
|
<input type="checkbox" class="user-checkbox" value="{{ user.username }}">
|
||||||
</td>
|
</td>
|
||||||
<td>{{ loop.index }}</td>
|
<td>{{ loop.index }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user['status'] == "Online" %}
|
{% if user.status == "Online" %}
|
||||||
<i class="fas fa-circle text-success"></i> Online
|
<i class="fas fa-circle text-success"></i> Online
|
||||||
{% elif user['status'] == "Offline" %}
|
{% if user.online_count and user.online_count > 0 %}
|
||||||
<i class="fas fa-circle text-secondary"></i> Offline
|
({{ user.online_count }})
|
||||||
{% elif user['status'] == "On-hold" %}
|
{% endif %}
|
||||||
<i class="fas fa-circle text-warning"></i> On-hold
|
{% elif user.status == "Offline" %}
|
||||||
{% elif user['status'] == "Conflict" %}
|
<i class="fas fa-circle text-secondary"></i> Offline
|
||||||
<i class="fas fa-circle text-danger"></i> Conflict
|
{% elif user.status == "On-hold" %}
|
||||||
|
<i class="fas fa-circle text-warning"></i> On-hold
|
||||||
|
{% elif user.status == "Conflict" %}
|
||||||
|
<i class="fas fa-circle text-danger"></i> Conflict
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="fas fa-circle text-danger"></i> {{ user['status'] }}
|
<i class="fas fa-circle text-danger"></i> {{ user.status }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td data-username="{{ user['username'] }}">{{ user['username'] }}</td>
|
<td data-username="{{ user.username }}">{{ user.username }}</td>
|
||||||
<td>{{ user['traffic_used'] }}</td>
|
<td>{{ user.traffic_used }}</td>
|
||||||
<td>{{ user['expiry_date'] }}</td>
|
<td>{{ user.expiry_date }}</td>
|
||||||
<td>{{ user['expiry_days'] }}</td>
|
<td>{{ user.expiry_days }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if user['enable'] %}
|
{% if user.enable %}
|
||||||
<i class="fas fa-check-circle text-success"></i>
|
<i class="fas fa-check-circle text-success"></i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="fas fa-times-circle text-danger"></i>
|
<i class="fas fa-times-circle text-danger"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="unlimited-ip-cell requires-iplimit-service" style="display: none;">
|
<td class="unlimited-ip-cell requires-iplimit-service" style="display: none;">
|
||||||
{% if user['unlimited_ip'] %}
|
{% if user.unlimited_ip %}
|
||||||
<i class="fas fa-shield-alt text-primary"></i>
|
<i class="fas fa-shield-alt text-primary"></i>
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="fas fa-times-circle text-muted"></i>
|
<i class="fas fa-times-circle text-muted"></i>
|
||||||
@ -129,22 +132,22 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<a href="#" class="config-link" data-toggle="modal" data-target="#qrcodeModal"
|
<a href="#" class="config-link" data-toggle="modal" data-target="#qrcodeModal"
|
||||||
data-username="{{ user['username'] }}">
|
data-username="{{ user.username }}">
|
||||||
<i class="fas fa-qrcode"></i>
|
<i class="fas fa-qrcode"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
<button type="button" class="btn btn-sm btn-info edit-user"
|
<button type="button" class="btn btn-sm btn-info edit-user"
|
||||||
data-user="{{ user['username'] }}" data-toggle="modal"
|
data-user="{{ user.username }}" data-toggle="modal"
|
||||||
data-target="#editUserModal">
|
data-target="#editUserModal">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-warning reset-user"
|
<button type="button" class="btn btn-sm btn-warning reset-user"
|
||||||
data-user="{{ user['username'] }}">
|
data-user="{{ user.username }}">
|
||||||
<i class="fas fa-undo"></i>
|
<i class="fas fa-undo"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-danger delete-user"
|
<button type="button" class="btn btn-sm btn-danger delete-user"
|
||||||
data-user="{{ user['username'] }}">
|
data-user="{{ user.username }}">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@ -303,18 +306,37 @@
|
|||||||
<div class="modal-dialog modal-lg" role="document">
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="showLinksModalLabel">Selected User Links</h5>
|
<h5 class="modal-title" id="showLinksModalLabel">Extract User Links</h5>
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Here are the Normal-SUB links for the selected users:</p>
|
<p>Select the link types you want to extract for the selected users:</p>
|
||||||
<textarea id="linksTextarea" class="form-control" rows="10" readonly></textarea>
|
<div class="form-group">
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="extractIPv4" value="ipv4">
|
||||||
|
<label class="form-check-label" for="extractIPv4">IPv4</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="extractIPv6" value="ipv6">
|
||||||
|
<label class="form-check-label" for="extractIPv6">IPv6</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="extractNormalSub" value="normal_sub" checked>
|
||||||
|
<label class="form-check-label" for="extractNormalSub">Normal SUB</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" id="extractNodes" value="nodes">
|
||||||
|
<label class="form-check-label" for="extractNodes">Nodes</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary mb-3" id="extractLinksButton">Extract Links</button>
|
||||||
|
<textarea id="linksTextarea" class="form-control" rows="10" readonly placeholder="Extracted links will appear here..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||||
<button type="button" class="btn btn-primary" id="copyLinksButton">Copy All</button>
|
<button type="button" class="btn btn-success" id="copyExtractedLinksButton">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -328,6 +350,7 @@
|
|||||||
<script>
|
<script>
|
||||||
$(function () {
|
$(function () {
|
||||||
const usernameRegex = /^[a-zA-Z0-9_]+$/;
|
const usernameRegex = /^[a-zA-Z0-9_]+$/;
|
||||||
|
let cachedUserData = [];
|
||||||
|
|
||||||
function checkIpLimitServiceStatus() {
|
function checkIpLimitServiceStatus() {
|
||||||
$.getJSON('{{ url_for("server_services_status_api") }}')
|
$.getJSON('{{ url_for("server_services_status_api") }}')
|
||||||
@ -515,23 +538,72 @@
|
|||||||
data: JSON.stringify({ usernames: selectedUsers }),
|
data: JSON.stringify({ usernames: selectedUsers }),
|
||||||
}).done(results => {
|
}).done(results => {
|
||||||
Swal.close();
|
Swal.close();
|
||||||
const allLinks = results.map(res => res.normal_sub).filter(Boolean);
|
cachedUserData = results;
|
||||||
const failedCount = selectedUsers.length - allLinks.length;
|
|
||||||
|
const fetchedCount = results.length;
|
||||||
|
const failedCount = selectedUsers.length - fetchedCount;
|
||||||
|
|
||||||
if (failedCount > 0) {
|
if (failedCount > 0) {
|
||||||
Swal.fire('Warning', `Could not fetch links for ${failedCount} user(s), but others were successful.`, 'warning');
|
Swal.fire('Warning', `Could not fetch info for ${failedCount} user(s), but others were successful.`, 'warning');
|
||||||
}
|
}
|
||||||
if (allLinks.length > 0) {
|
|
||||||
$("#linksTextarea").val(allLinks.join('\n'));
|
if (fetchedCount > 0) {
|
||||||
|
const hasIPv4 = cachedUserData.some(user => user.ipv4);
|
||||||
|
const hasIPv6 = cachedUserData.some(user => user.ipv6);
|
||||||
|
const hasNormalSub = cachedUserData.some(user => user.normal_sub);
|
||||||
|
const hasNodes = cachedUserData.some(user => user.nodes && user.nodes.length > 0);
|
||||||
|
|
||||||
|
$("#extractIPv4").closest('.form-check-inline').toggle(hasIPv4);
|
||||||
|
$("#extractIPv6").closest('.form-check-inline').toggle(hasIPv6);
|
||||||
|
$("#extractNormalSub").closest('.form-check-inline').toggle(hasNormalSub);
|
||||||
|
$("#extractNodes").closest('.form-check-inline').toggle(hasNodes);
|
||||||
|
|
||||||
|
$("#linksTextarea").val('');
|
||||||
$("#showLinksModal").modal("show");
|
$("#showLinksModal").modal("show");
|
||||||
} else {
|
} else {
|
||||||
Swal.fire('Error', `Could not fetch links for any of the selected users.`, 'error');
|
Swal.fire('Error', `Could not fetch info for any of the selected users.`, 'error');
|
||||||
}
|
}
|
||||||
}).fail(() => Swal.fire('Error!', 'An error occurred while fetching the links.', 'error'));
|
}).fail(() => Swal.fire('Error!', 'An error occurred while fetching the links.', 'error'));
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#copyLinksButton").on("click", () => {
|
$("#extractLinksButton").on("click", function () {
|
||||||
navigator.clipboard.writeText($("#linksTextarea").val())
|
const allLinks = [];
|
||||||
.then(() => Swal.fire({ icon: "success", title: "All links copied!", showConfirmButton: false, timer: 1200 }));
|
const linkTypes = {
|
||||||
|
ipv4: $("#extractIPv4").is(":checked"),
|
||||||
|
ipv6: $("#extractIPv6").is(":checked"),
|
||||||
|
normal_sub: $("#extractNormalSub").is(":checked"),
|
||||||
|
nodes: $("#extractNodes").is(":checked")
|
||||||
|
};
|
||||||
|
|
||||||
|
cachedUserData.forEach(user => {
|
||||||
|
if (linkTypes.ipv4 && user.ipv4) {
|
||||||
|
allLinks.push(user.ipv4);
|
||||||
|
}
|
||||||
|
if (linkTypes.ipv6 && user.ipv6) {
|
||||||
|
allLinks.push(user.ipv6);
|
||||||
|
}
|
||||||
|
if (linkTypes.normal_sub && user.normal_sub) {
|
||||||
|
allLinks.push(user.normal_sub);
|
||||||
|
}
|
||||||
|
if (linkTypes.nodes && user.nodes && user.nodes.length > 0) {
|
||||||
|
user.nodes.forEach(node => {
|
||||||
|
if (node.uri) {
|
||||||
|
allLinks.push(node.uri);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#linksTextarea").val(allLinks.join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#copyExtractedLinksButton").on("click", () => {
|
||||||
|
const links = $("#linksTextarea").val();
|
||||||
|
if (!links) {
|
||||||
|
return Swal.fire({ icon: "info", title: "Nothing to copy!", text: "Please extract some links first.", showConfirmButton: false, timer: 1500 });
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(links)
|
||||||
|
.then(() => Swal.fire({ icon: "success", title: "Links copied!", showConfirmButton: false, timer: 1200 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
function filterUsers() {
|
function filterUsers() {
|
||||||
|
|||||||
Reference in New Issue
Block a user