feat(users): Integrate real-time online status and enhance UI

This commit is contained in:
Whispering Wind
2025-09-05 13:16:41 +02:00
committed by GitHub
parent c9235b2b36
commit d5d4935f69
6 changed files with 173 additions and 44 deletions

View File

@ -40,7 +40,7 @@ class Command(Enum):
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)
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')
BACKUP_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'backup.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.
'''
if res := run_cmd(['bash', Command.LIST_USERS.value]):
if res := run_cmd(['python3', Command.LIST_USERS.value]):
return json.loads(res)

View 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()

View File

@ -4,19 +4,21 @@ from pydantic import BaseModel, RootModel, Field, field_validator
class UserInfoResponse(BaseModel):
username: str
password: str
max_download_bytes: int
expiration_days: int
account_creation_date: str
account_creation_date: Optional[str] = None
blocked: bool
unlimited_ip: bool = Field(False, alias='unlimited_user')
status: Optional[str] = None
upload_bytes: Optional[int] = None
download_bytes: Optional[int] = None
online_count: int = 0
class UserListResponse(RootModel):
root: dict[str, UserInfoResponse]
root: List[UserInfoResponse]
class UsernamesRequest(BaseModel):
usernames: List[str]

View File

@ -1,4 +1,3 @@
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi.templating import Jinja2Templates
@ -13,10 +12,8 @@ router = APIRouter()
@router.get('/')
async def users(request: Request, templates: Jinja2Templates = Depends(get_templates)):
try:
dict_users = cli_api.list_users() # type: ignore
users: list[User] = []
if dict_users:
users: list[User] = [User.from_dict(key, value) for key, value in dict_users.items()] # type: ignore
users_list = cli_api.list_users() or []
users: list[User] = [User.from_dict(user_data.get('username', ''), user_data) for user_data in users_list]
return templates.TemplateResponse('users.html', {'users': users, 'request': request})
except Exception as e:

View File

@ -11,6 +11,7 @@ class User(BaseModel):
expiry_days: str
enable: bool
unlimited_ip: bool
online_count: int = 0
@staticmethod
def from_dict(username: str, user_data: dict):
@ -36,7 +37,8 @@ class User(BaseModel):
'expiry_date': 'N/A',
'expiry_days': 'N/A',
'enable': False,
'unlimited_ip': False
'unlimited_ip': False,
'online_count': 0
}
expiration_days = user_data.get('expiration_days', 0)
@ -77,7 +79,8 @@ class User(BaseModel):
'expiry_date': display_expiry_date,
'expiry_days': display_expiry_days,
'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

View File

@ -93,35 +93,38 @@
{% for user in users|sort(attribute='username', case_sensitive=false) %}
<tr>
<td>
<input type="checkbox" class="user-checkbox" value="{{ user['username'] }}">
<input type="checkbox" class="user-checkbox" value="{{ user.username }}">
</td>
<td>{{ loop.index }}</td>
<td>
{% if user['status'] == "Online" %}
<i class="fas fa-circle text-success"></i> Online
{% elif user['status'] == "Offline" %}
<i class="fas fa-circle text-secondary"></i> Offline
{% 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
{% if user.status == "Online" %}
<i class="fas fa-circle text-success"></i> Online
{% if user.online_count and user.online_count > 0 %}
({{ user.online_count }})
{% endif %}
{% elif user.status == "Offline" %}
<i class="fas fa-circle text-secondary"></i> Offline
{% 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 %}
<i class="fas fa-circle text-danger"></i> {{ user['status'] }}
<i class="fas fa-circle text-danger"></i> {{ user.status }}
{% endif %}
</td>
<td data-username="{{ user['username'] }}">{{ user['username'] }}</td>
<td>{{ user['traffic_used'] }}</td>
<td>{{ user['expiry_date'] }}</td>
<td>{{ user['expiry_days'] }}</td>
<td data-username="{{ user.username }}">{{ user.username }}</td>
<td>{{ user.traffic_used }}</td>
<td>{{ user.expiry_date }}</td>
<td>{{ user.expiry_days }}</td>
<td>
{% if user['enable'] %}
{% if user.enable %}
<i class="fas fa-check-circle text-success"></i>
{% else %}
<i class="fas fa-times-circle text-danger"></i>
{% endif %}
</td>
<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>
{% else %}
<i class="fas fa-times-circle text-muted"></i>
@ -129,22 +132,22 @@
</td>
<td class="text-nowrap">
<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>
</a>
</td>
<td class="text-nowrap">
<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">
<i class="fas fa-edit"></i>
</button>
<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>
</button>
<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>
</button>
</td>
@ -303,18 +306,37 @@
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<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">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Here are the Normal-SUB links for the selected users:</p>
<textarea id="linksTextarea" class="form-control" rows="10" readonly></textarea>
<p>Select the link types you want to extract for the selected users:</p>
<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 class="modal-footer">
<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>
@ -328,6 +350,7 @@
<script>
$(function () {
const usernameRegex = /^[a-zA-Z0-9_]+$/;
let cachedUserData = [];
function checkIpLimitServiceStatus() {
$.getJSON('{{ url_for("server_services_status_api") }}')
@ -515,23 +538,72 @@
data: JSON.stringify({ usernames: selectedUsers }),
}).done(results => {
Swal.close();
const allLinks = results.map(res => res.normal_sub).filter(Boolean);
const failedCount = selectedUsers.length - allLinks.length;
cachedUserData = results;
const fetchedCount = results.length;
const failedCount = selectedUsers.length - fetchedCount;
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");
} 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'));
});
$("#copyLinksButton").on("click", () => {
navigator.clipboard.writeText($("#linksTextarea").val())
.then(() => Swal.fire({ icon: "success", title: "All links copied!", showConfirmButton: false, timer: 1200 }));
$("#extractLinksButton").on("click", function () {
const allLinks = [];
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() {