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')
|
||||
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)
|
||||
|
||||
|
||||
|
||||
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):
|
||||
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]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">×</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() {
|
||||
|
||||
Reference in New Issue
Block a user