Use uv to manage environment
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
62
README.md
Normal file
62
README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Hysteria Control Panel
|
||||
|
||||
A desktop GUI application for managing and monitoring a Hysteria server. This control panel provides a user-friendly interface to interact with the Hysteria server's API, allowing administrators to view server statistics and manage users.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Secure Connection**: Connect to your Hysteria server's API using its address, port, and secret.
|
||||
- **Connection Profiles**: Save connection details (except for the secret, optionally) for quick access.
|
||||
- **Traffic Monitoring**: View real-time and total data usage (Upload/Download) for all users.
|
||||
- **Online User Management**: See a list of currently connected users and their IP addresses.
|
||||
- **User Disconnection**: Forcefully disconnect (kick) one or more users from the server.
|
||||
- **Active Stream Viewer**: Monitor the active data streams on the server.
|
||||
- **Auto-Refresh**: Automatically updates statistics every 2 seconds to provide a live view of the server status.
|
||||
|
||||
## Technical Details
|
||||
|
||||
This application is built with Python and leverages the following libraries:
|
||||
|
||||
- **UI Framework**: [ttkbootstrap](https://github.com/israel-dryer/ttkbootstrap)
|
||||
- The user interface is built using `ttkbootstrap`, a theme extension for Python's standard `tkinter` GUI library. It provides modern and professional-looking widgets.
|
||||
- **API Communication**: [requests](https://requests.readthedocs.io/en/latest/)
|
||||
- The `requests` library is used to handle all HTTP communication with the Hysteria server's REST API.
|
||||
- **Configuration**:
|
||||
- Connection profiles are stored locally in a `config.json` file located in the user's system-specific configuration directory (`~/.config/hysteria_panel/` on Linux/macOS).
|
||||
|
||||
### Project Structure
|
||||
|
||||
The project is organized into several modules:
|
||||
|
||||
- `src/hysteria_panel/`: Main package directory.
|
||||
- `__main__.py`: The main entry point of the application.
|
||||
- `gui.py`: Handles the main application window, layout, connection logic, and tab management.
|
||||
- `api.py`: Contains the `HysteriaAPI` class, which encapsulates all the logic for making API calls to the server.
|
||||
- `utils.py`: Provides helper functions for formatting data (e.g., bytes to KB/MB/GB) and managing the configuration file.
|
||||
- `views/`: A sub-package containing the different UI tabs (Traffic, Online Users, Streams) as separate modules for better organization.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- A running Hysteria server with the API enabled.
|
||||
|
||||
### Installation & Running
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/your-username/hysteria-panel.git
|
||||
cd hysteria-panel
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Run the application:**
|
||||
```bash
|
||||
python -m src.hysteria_panel
|
||||
```
|
||||
6
main.py
Normal file
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from hysteria-panel-python!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "hysteria-panel"
|
||||
version = "0.2.3"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"requests>=2.32.5",
|
||||
"ttkbootstrap>=1.14.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
socks = [
|
||||
"requests[socks]>=2.32.5",
|
||||
]
|
||||
|
||||
[project.gui-scripts]
|
||||
hysteria-panel = "hysteria_panel.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.21.1"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/hysteria_panel"]
|
||||
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 535 KiB |
14
src/hysteria_panel/__init__.py
Normal file
14
src/hysteria_panel/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
try:
|
||||
from importlib.metadata import version # type: ignore
|
||||
__version__ = version("hysteria-panel")
|
||||
except ImportError:
|
||||
# Python < 3.8
|
||||
from importlib_metadata import version # type: ignore
|
||||
__version__ = version("hysteria-panel")
|
||||
except Exception:
|
||||
# Fallback for development/uninstalled package
|
||||
__version__ = "1.0.0"
|
||||
|
||||
from .api import HysteriaAPI
|
||||
from .gui import HysteriaGUI
|
||||
from . import utils
|
||||
8
src/hysteria_panel/__main__.py
Normal file
8
src/hysteria_panel/__main__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from . import HysteriaGUI
|
||||
|
||||
def main():
|
||||
app = HysteriaGUI()
|
||||
app.mainloop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
47
src/hysteria_panel/api.py
Normal file
47
src/hysteria_panel/api.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from typing import Any, Dict, List, Optional, Any, Tuple
|
||||
import requests
|
||||
|
||||
from . import __version__
|
||||
|
||||
class HysteriaAPI:
|
||||
def __init__(self, host: str, port: int, secret: str):
|
||||
self.base_url = f"http://{host}:{port}"
|
||||
self.timeout = 5 # seconds
|
||||
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({'Authorization': secret})
|
||||
self._session.headers.update({'User-Agent': f'HysteriaPanel/{__version__}'})
|
||||
|
||||
success, data = self._request('get', '/')
|
||||
if not success:
|
||||
raise ValueError(f"Failed to connect to Hysteria API: {data}")
|
||||
|
||||
self._session.headers.update({'Accept': 'application/json'})
|
||||
|
||||
def _request(self, method: str, endpoint: str, data: Optional[Dict | List] = None, params: Optional[Dict | List] = None, headers: Optional[Dict] = None) -> Tuple[bool, Any]:
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
try:
|
||||
response = self._session.request(
|
||||
method, url, headers=headers, json=data, params=params, timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
if endpoint == '/kick':
|
||||
return True, None
|
||||
content = response.json() if ('application/json' in response.headers.get('Content-Type', '')) else response.text
|
||||
return True, content
|
||||
except requests.exceptions.RequestException as e:
|
||||
return False, str(e)
|
||||
|
||||
def get_traffic(self, clear=False):
|
||||
""" {username: {rx: int, tx: int}} """
|
||||
params = {'clear': '1'} if clear else None
|
||||
return self._request('get', '/traffic', params=params)
|
||||
|
||||
def get_online_users(self):
|
||||
return self._request('get', '/online')
|
||||
|
||||
def kick_users(self, users: List[str]):
|
||||
return self._request('post', '/kick', data=users)
|
||||
|
||||
def get_streams(self, readable: bool = False):
|
||||
return self._request('get', '/dump/streams', headers={'Accept': 'text/plain'} if readable else None)
|
||||
126
src/hysteria_panel/gui.py
Normal file
126
src/hysteria_panel/gui.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from typing import Optional
|
||||
import ttkbootstrap as tb
|
||||
from ttkbootstrap.constants import BOTH, RIGHT, X
|
||||
|
||||
from . import HysteriaAPI, utils, __version__
|
||||
from .views import LoginView, TrafficView, OnlineUsersView, StreamsView
|
||||
|
||||
class HysteriaGUI(tb.Window):
|
||||
def __init__(self):
|
||||
super().__init__(themename="darkly")
|
||||
self.title(f"Hysteria Control Panel - v{__version__}")
|
||||
self.geometry("1200x720")
|
||||
self._api: Optional[HysteriaAPI] = None
|
||||
self.auto_refresh_job: Optional[str] = None
|
||||
self.auto_refresh_enabled = tb.BooleanVar(value=True)
|
||||
|
||||
self.login_frame = LoginView(self, on_connect=self.connect_to_api, padding=(20, 20))
|
||||
self.login_frame.pack(fill=BOTH, expand=True)
|
||||
|
||||
self._load_saved_config()
|
||||
|
||||
@property
|
||||
def api(self) -> HysteriaAPI:
|
||||
if self._api is None:
|
||||
raise ValueError("API not initialized. Please connect first.")
|
||||
return self._api
|
||||
|
||||
def _load_saved_config(self):
|
||||
config = utils.load_config()
|
||||
if not config:
|
||||
return
|
||||
|
||||
self.login_frame.host_entry.delete(0, "end")
|
||||
self.login_frame.port_entry.delete(0, "end")
|
||||
self.login_frame.host_entry.insert(0, f'{config.get("host", "")}')
|
||||
self.login_frame.port_entry.insert(0, f'{config.get("port", "")}')
|
||||
self.login_frame.remember_me_var.set(True)
|
||||
|
||||
if "secret" in config:
|
||||
self.login_frame.secret_entry.insert(0, f'{config.get("secret", "")}')
|
||||
self.login_frame.save_password_var.set(True)
|
||||
|
||||
def _save_config(self):
|
||||
config = {
|
||||
"host": self.login_frame.host_entry.get().strip(),
|
||||
"port": int(self.login_frame.port_entry.get().strip()),
|
||||
}
|
||||
if self.login_frame.save_password_var.get():
|
||||
config["secret"] = self.login_frame.secret_entry.get().strip()
|
||||
|
||||
utils.save_config(config)
|
||||
|
||||
def connect_to_api(self) -> bool:
|
||||
host = self.login_frame.host_entry.get().strip()
|
||||
port = self.login_frame.port_entry.get().strip()
|
||||
secret = self.login_frame.secret_entry.get().strip()
|
||||
|
||||
if not host:
|
||||
self.login_frame.status_label.config(text="Host cannot be empty.")
|
||||
return False
|
||||
if not port.isdigit():
|
||||
self.login_frame.status_label.config(text="Port must be a number.")
|
||||
return False
|
||||
if not secret:
|
||||
self.login_frame.status_label.config(text="Secret cannot be empty.")
|
||||
return False
|
||||
|
||||
try:
|
||||
self._api = HysteriaAPI(host, int(port), secret)
|
||||
|
||||
if self.login_frame.remember_me_var.get():
|
||||
self._save_config()
|
||||
else:
|
||||
utils.clear_config()
|
||||
|
||||
self._create_main_frame()
|
||||
self.login_frame.pack_forget()
|
||||
self.main_frame.pack(fill=BOTH, expand=True)
|
||||
self.toggle_auto_refresh()
|
||||
return True
|
||||
except ValueError as e:
|
||||
self.login_frame.status_label.config(text=f"Connection Failed: {e}")
|
||||
return False
|
||||
|
||||
def _create_main_frame(self):
|
||||
self.main_frame = tb.Frame(self, padding=(10, 10))
|
||||
|
||||
top_controls_frame = tb.Frame(self.main_frame)
|
||||
top_controls_frame.pack(fill=X, pady=(0, 5))
|
||||
|
||||
self.auto_refresh_check = tb.Checkbutton(
|
||||
top_controls_frame,
|
||||
text="Real-time updates (2s)",
|
||||
variable=self.auto_refresh_enabled,
|
||||
bootstyle="primary-round-toggle", # type: ignore # type: ignore
|
||||
command=self.toggle_auto_refresh
|
||||
)
|
||||
self.auto_refresh_check.pack(side=RIGHT)
|
||||
|
||||
self.notebook = tb.Notebook(self.main_frame)
|
||||
self.notebook.pack(fill=BOTH, expand=True)
|
||||
|
||||
self.traffic_tab = TrafficView(self.notebook, api=self.api, padding=10)
|
||||
self.online_users_tab = OnlineUsersView(self.notebook, api=self.api, padding=10)
|
||||
self.streams_tab = StreamsView(self.notebook, api=self.api, padding=10)
|
||||
self.notebook.add(self.traffic_tab, text="Traffic Stats")
|
||||
self.notebook.add(self.online_users_tab, text="Online Users")
|
||||
self.notebook.add(self.streams_tab, text="Active Streams")
|
||||
|
||||
def toggle_auto_refresh(self):
|
||||
if self.auto_refresh_enabled.get():
|
||||
self.auto_refresh_loop()
|
||||
elif self.auto_refresh_job:
|
||||
self.after_cancel(self.auto_refresh_job)
|
||||
self.auto_refresh_job = None
|
||||
|
||||
def auto_refresh_loop(self):
|
||||
self.refresh_all()
|
||||
self.auto_refresh_job = self.after(2000, self.auto_refresh_loop) # 2 seconds
|
||||
|
||||
def refresh_all(self):
|
||||
# do not show error popups when auto refreshing
|
||||
show_errors = not self.auto_refresh_enabled.get()
|
||||
self.traffic_tab.refresh_traffic(show_error_popup=show_errors)
|
||||
self.online_users_tab.refresh_online_users(show_error_popup=show_errors)
|
||||
self.streams_tab.refresh_streams(show_error_popup=show_errors)
|
||||
50
src/hysteria_panel/utils.py
Normal file
50
src/hysteria_panel/utils.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import math
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
CONFIG_DIR = Path.home() / ".config" / "hysteria_panel"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
|
||||
def ensure_config_dir_exists():
|
||||
"""Ensures the configuration directory exists."""
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save_config(data: Dict[str, str | int | bool]):
|
||||
"""Saves configuration data to the config file."""
|
||||
ensure_config_dir_exists()
|
||||
with open(CONFIG_FILE, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
def load_config() -> Dict[str, str | int | bool]:
|
||||
"""Loads configuration data from the config file."""
|
||||
if not CONFIG_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(CONFIG_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
|
||||
def clear_config():
|
||||
"""Clears the configuration file."""
|
||||
if CONFIG_FILE.exists():
|
||||
CONFIG_FILE.unlink()
|
||||
|
||||
def format_bytes(size_bytes):
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
size_name = ("B", "KB", "MB", "GB", "TB")
|
||||
i = min(int(math.floor(math.log(size_bytes, 1024))), len(size_name) - 1)
|
||||
p = math.pow(1024, i)
|
||||
s = round(size_bytes / p, 2)
|
||||
return f"{s:.2f} {size_name[i]}"
|
||||
|
||||
def format_speed(speed_bytes_per_second):
|
||||
if speed_bytes_per_second == 0:
|
||||
return "0 B/s"
|
||||
size_name = ("B/s", "KB/s", "MB/s", "GB/s", "TB/s")
|
||||
i = min(int(math.floor(math.log(speed_bytes_per_second, 1024))), len(size_name) - 1)
|
||||
p = math.pow(1024, i)
|
||||
s = round(speed_bytes_per_second / p, 2)
|
||||
return f"{s:.2f} {size_name[i]}"
|
||||
4
src/hysteria_panel/views/__init__.py
Normal file
4
src/hysteria_panel/views/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .login_view import LoginView
|
||||
from .online_users_view import OnlineUsersView
|
||||
from .streams_view import StreamsView
|
||||
from .traffic_view import TrafficView
|
||||
52
src/hysteria_panel/views/login_view.py
Normal file
52
src/hysteria_panel/views/login_view.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import ttkbootstrap as tb
|
||||
from ttkbootstrap.constants import PRIMARY, SUCCESS, DANGER
|
||||
|
||||
class LoginView(tb.Frame):
|
||||
def __init__(self, master, on_connect, **kwargs):
|
||||
super().__init__(master, **kwargs)
|
||||
self.on_connect = on_connect
|
||||
|
||||
heading = tb.Label(self, text="Hysteria Control", font=("Helvetica", 24, "bold"), bootstyle=PRIMARY) # type: ignore
|
||||
heading.pack(pady=20)
|
||||
|
||||
form_frame = tb.Frame(self)
|
||||
form_frame.pack(pady=20)
|
||||
|
||||
self.host_label = tb.Label(form_frame, text="API Host:")
|
||||
self.host_label.grid(row=0, column=0, padx=10, pady=10, sticky="w")
|
||||
self.host_entry = tb.Entry(form_frame, width=40)
|
||||
self.host_entry.insert(0, "127.0.0.1")
|
||||
self.host_entry.grid(row=0, column=1, padx=10, pady=10)
|
||||
|
||||
self.port_label = tb.Label(form_frame, text="API Port:")
|
||||
self.port_label.grid(row=1, column=0, padx=10, pady=10, sticky="w")
|
||||
self.port_entry = tb.Entry(form_frame, width=40)
|
||||
self.port_entry.insert(0, "9999")
|
||||
self.port_entry.grid(row=1, column=1, padx=10, pady=10)
|
||||
|
||||
self.secret_label = tb.Label(form_frame, text="API Secret:")
|
||||
self.secret_label.grid(row=2, column=0, padx=10, pady=10, sticky="w")
|
||||
self.secret_entry = tb.Entry(form_frame, width=40, show="*")
|
||||
self.secret_entry.grid(row=2, column=1, padx=10, pady=10)
|
||||
self.secret_entry.bind("<Return>", lambda event: self.on_connect())
|
||||
|
||||
# Remember Me and Save Password Checkboxes
|
||||
remember_frame = tb.Frame(self)
|
||||
remember_frame.pack(pady=5)
|
||||
|
||||
self.remember_me_var = tb.BooleanVar()
|
||||
self.remember_me_check = tb.Checkbutton(remember_frame, text="Remember Me", variable=self.remember_me_var, bootstyle="primary") # type: ignore
|
||||
self.remember_me_check.pack(side="left", padx=10)
|
||||
|
||||
self.save_password_var = tb.BooleanVar()
|
||||
self.save_password_check = tb.Checkbutton(remember_frame, text="Save Password", variable=self.save_password_var, bootstyle="primary") # type: ignore
|
||||
self.save_password_check.pack(side="left", padx=10)
|
||||
|
||||
password_warning_label = tb.Label(remember_frame, text="(Warning: Saved in plain text)", font=("Helvetica", 8), bootstyle="warning") # type: ignore
|
||||
password_warning_label.pack(side="left")
|
||||
|
||||
self.connect_button = tb.Button(self, text="Connect", command=self.on_connect, bootstyle=SUCCESS) # type: ignore
|
||||
self.connect_button.pack(pady=20)
|
||||
|
||||
self.status_label = tb.Label(self, text="", bootstyle=DANGER) # type: ignore
|
||||
self.status_label.pack(pady=10)
|
||||
74
src/hysteria_panel/views/online_users_view.py
Normal file
74
src/hysteria_panel/views/online_users_view.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import ttkbootstrap as tb
|
||||
from ttkbootstrap.constants import BOTH, PRIMARY, INFO, DANGER, END, LEFT, RIGHT, CENTER, X
|
||||
from ttkbootstrap.dialogs import Messagebox
|
||||
|
||||
class OnlineUsersView(tb.Frame):
|
||||
def __init__(self, parent, api, **kwargs):
|
||||
super().__init__(parent, **kwargs)
|
||||
self.api = api
|
||||
|
||||
# Top frame for refresh and kick controls
|
||||
controls_frame = tb.Frame(self)
|
||||
controls_frame.pack(fill=X, pady=5)
|
||||
|
||||
tb.Button(controls_frame, text="Refresh", command=self.refresh_online_users, bootstyle=PRIMARY).pack(side=LEFT, padx=5) # type: ignore
|
||||
|
||||
kick_frame = tb.Frame(controls_frame)
|
||||
kick_frame.pack(side=RIGHT, padx=5)
|
||||
|
||||
self.kick_entry = tb.Entry(kick_frame, width=20)
|
||||
self.kick_entry.pack(side=LEFT, padx=(0, 5))
|
||||
self.kick_button = tb.Button(kick_frame, text="Kick User", command=self.kick_user, bootstyle=DANGER) # type: ignore
|
||||
self.kick_button.pack(side=LEFT)
|
||||
|
||||
self.online_tree = tb.Treeview(
|
||||
self,
|
||||
columns=("user", "connections"),
|
||||
show="headings",
|
||||
bootstyle=INFO # type: ignore
|
||||
)
|
||||
self.online_tree.heading("user", text="User")
|
||||
self.online_tree.heading("connections", text="Connections")
|
||||
self.online_tree.column("user", anchor=CENTER)
|
||||
self.online_tree.column("connections", anchor=CENTER)
|
||||
self.online_tree.pack(fill=BOTH, expand=True, pady=5)
|
||||
self.online_tree.bind("<<TreeviewSelect>>", self.on_user_select)
|
||||
|
||||
def refresh_online_users(self, show_error_popup=True):
|
||||
selected_users = {self.online_tree.item(item, 'values')[0] for item in self.online_tree.selection()}
|
||||
|
||||
self.online_tree.delete(*self.online_tree.get_children())
|
||||
|
||||
success, data = self.api.get_online_users()
|
||||
if success:
|
||||
for user, connections in data.items():
|
||||
self.online_tree.insert("", END, values=(user, connections))
|
||||
elif show_error_popup:
|
||||
Messagebox.show_error(title="Error", message=f"Failed to fetch online users:\n{data}")
|
||||
|
||||
if selected_users:
|
||||
selected_items = [item for item in self.online_tree.get_children() if self.online_tree.item(item, 'values')[0] in selected_users]
|
||||
self.online_tree.selection_set(selected_items)
|
||||
|
||||
def on_user_select(self, _):
|
||||
selected_users = {self.online_tree.item(item, "values")[0] for item in self.online_tree.selection()}
|
||||
if selected_users:
|
||||
self.kick_entry.delete(0, END)
|
||||
self.kick_entry.insert(0, ", ".join(selected_users))
|
||||
|
||||
def kick_user(self):
|
||||
user_to_kick = self.kick_entry.get().strip()
|
||||
if not user_to_kick:
|
||||
Messagebox.show_warning(title="Warning", message="Please enter a username to kick.")
|
||||
return
|
||||
|
||||
kick_list = user_to_kick.split(", ")
|
||||
|
||||
yesno = Messagebox.yesno(title="Confirm Kick", message=f"Are you sure you want to kick {kick_list}?")
|
||||
if yesno == "Yes":
|
||||
success, data = self.api.kick_users(kick_list)
|
||||
if success:
|
||||
Messagebox.ok(title="Success", message=f"Request to kick {kick_list} sent successfully.")
|
||||
self.refresh_online_users()
|
||||
else:
|
||||
Messagebox.show_error(title="Error", message=f"Failed to kick user:\n{data}")
|
||||
41
src/hysteria_panel/views/streams_view.py
Normal file
41
src/hysteria_panel/views/streams_view.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import ttkbootstrap as tb
|
||||
from ttkbootstrap.constants import BOTH, PRIMARY, OUTLINE, END, NORMAL, DISABLED, WORD, LEFT, X
|
||||
from ttkbootstrap.dialogs import Messagebox
|
||||
import json
|
||||
|
||||
class StreamsView(tb.Frame):
|
||||
def __init__(self, parent, api, **kwargs):
|
||||
super().__init__(parent, **kwargs)
|
||||
self.api = api
|
||||
self.plain_text_mode = tb.BooleanVar(value=False)
|
||||
|
||||
controls_frame = tb.Frame(self)
|
||||
controls_frame.pack(fill=X, pady=5)
|
||||
|
||||
def toggle_plain_text_mode():
|
||||
self.plain_text_mode.set(not self.plain_text_mode.get())
|
||||
switch_btn.config(text=f"Change to {'Plain Text' if not self.plain_text_mode.get() else 'JSON'}")
|
||||
self.refresh_streams()
|
||||
|
||||
tb.Button(controls_frame, text="Refresh", command=self.refresh_streams, bootstyle=PRIMARY).pack(side=LEFT, padx=5) # type: ignore
|
||||
switch_btn = tb.Button(controls_frame, text="Change to Plain Text", command=toggle_plain_text_mode, bootstyle=OUTLINE) # type: ignore
|
||||
switch_btn.pack(side=LEFT, padx=5)
|
||||
|
||||
self.streams_text = tb.Text(self, wrap=WORD, font=("Courier New", 10))
|
||||
self.streams_text.pack(fill=BOTH, expand=True, pady=5)
|
||||
self.streams_text.config(state=DISABLED)
|
||||
|
||||
def refresh_streams(self, show_error_popup=True):
|
||||
self.streams_text.config(state=NORMAL)
|
||||
self.streams_text.delete(1.0, END)
|
||||
|
||||
success, data = self.api.get_streams(readable=self.plain_text_mode.get())
|
||||
if success:
|
||||
# Pretty print the JSON data
|
||||
content = data if self.plain_text_mode.get() else json.dumps(data, indent=2)
|
||||
self.streams_text.insert(END, content)
|
||||
else:
|
||||
self.streams_text.insert(END, f"Failed to fetch active streams:\n{data}")
|
||||
if show_error_popup:
|
||||
Messagebox.show_error(title="Error", message=f"Failed to fetch active streams:\n{data}")
|
||||
self.streams_text.config(state=DISABLED)
|
||||
58
src/hysteria_panel/views/traffic_view.py
Normal file
58
src/hysteria_panel/views/traffic_view.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import ttkbootstrap as tb
|
||||
from ttkbootstrap.constants import BOTH, PRIMARY, DANGER, OUTLINE, END, LEFT, CENTER, X
|
||||
from ttkbootstrap.dialogs import Messagebox
|
||||
from ..utils import format_bytes
|
||||
|
||||
class TrafficView(tb.Frame):
|
||||
def __init__(self, parent, api, **kwargs):
|
||||
super().__init__(parent, **kwargs)
|
||||
self.api = api
|
||||
|
||||
controls_frame = tb.Frame(self)
|
||||
controls_frame.pack(fill=X, pady=5)
|
||||
|
||||
tb.Button(controls_frame, text="Refresh", command=self.refresh_traffic, bootstyle=PRIMARY).pack(side=LEFT, padx=5) # type: ignore
|
||||
tb.Button(controls_frame, text="Clear Traffic Data", command=self.clear_traffic, bootstyle=(DANGER, OUTLINE)).pack(side=LEFT, padx=5) # type: ignore
|
||||
|
||||
self.traffic_tree = tb.Treeview(
|
||||
self,
|
||||
columns=("user", "tx", "rx"),
|
||||
show="headings",
|
||||
bootstyle=PRIMARY # type: ignore
|
||||
)
|
||||
self.traffic_tree.heading("user", text="User")
|
||||
self.traffic_tree.heading("tx", text="Upload (TX)")
|
||||
self.traffic_tree.heading("rx", text="Download (RX)")
|
||||
self.traffic_tree.column("user", anchor=CENTER)
|
||||
self.traffic_tree.column("tx", anchor=CENTER)
|
||||
self.traffic_tree.column("rx", anchor=CENTER)
|
||||
self.traffic_tree.pack(fill=BOTH, expand=True, pady=5)
|
||||
|
||||
def refresh_traffic(self, show_error_popup=True):
|
||||
# Store current selection and scroll position
|
||||
selected_users = {self.traffic_tree.item(item, 'values')[0] for item in self.traffic_tree.selection()}
|
||||
|
||||
self.traffic_tree.delete(*self.traffic_tree.get_children())
|
||||
|
||||
success, data = self.api.get_traffic()
|
||||
if success:
|
||||
for user, stats in data.items():
|
||||
tx = format_bytes(stats['tx'])
|
||||
rx = format_bytes(stats['rx'])
|
||||
self.traffic_tree.insert("", END, values=(user, tx, rx))
|
||||
elif show_error_popup:
|
||||
Messagebox.show_error(title="Error", message=f"Failed to fetch traffic stats:\n{data}")
|
||||
|
||||
# Restore selection
|
||||
if selected_users:
|
||||
selected_items = [item for item in self.traffic_tree.get_children() if self.traffic_tree.item(item, 'values')[0] in selected_users]
|
||||
self.traffic_tree.selection_set(selected_items)
|
||||
|
||||
def clear_traffic(self):
|
||||
if Messagebox.yesno(title="Confirm", message="Are you sure you want to clear all traffic data? This cannot be undone.") == "Yes":
|
||||
success, data = self.api.get_traffic(clear=True)
|
||||
if success:
|
||||
Messagebox.show_info(title="Success", message="Traffic data has been cleared.")
|
||||
self.refresh_traffic()
|
||||
else:
|
||||
Messagebox.show_error(title="Error", message=f"Failed to clear traffic data:\n{data}")
|
||||
143
uv.lock
generated
Normal file
143
uv.lock
generated
Normal file
@@ -0,0 +1,143 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hysteria-panel-python"
|
||||
version = "0.2.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "ttkbootstrap" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
socks = [
|
||||
{ name = "requests", extra = ["socks"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "requests", extras = ["socks"], marker = "extra == 'socks'", specifier = ">=2.32.5" },
|
||||
{ name = "ttkbootstrap", specifier = ">=1.14.2" },
|
||||
]
|
||||
provides-extras = ["socks"]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "10.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685, upload-time = "2024-07-01T09:46:45.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883, upload-time = "2024-07-01T09:46:47.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837, upload-time = "2024-07-01T09:46:49.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562, upload-time = "2024-07-01T09:46:51.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761, upload-time = "2024-07-01T09:46:53.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767, upload-time = "2024-07-01T09:46:56.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989, upload-time = "2024-07-01T09:46:58.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255, upload-time = "2024-07-01T09:47:01.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603, upload-time = "2024-07-01T09:47:03.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972, upload-time = "2024-07-01T09:47:06.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375, upload-time = "2024-07-01T09:47:09.065Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pysocks"
|
||||
version = "1.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
socks = [
|
||||
{ name = "pysocks" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttkbootstrap"
|
||||
version = "1.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pillow" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/8a/fe21d5dff1c77796633e39a37e356033f21a48ca061ba6e1a4bb259f2f56/ttkbootstrap-1.14.2.tar.gz", hash = "sha256:aae7885211bdeb134c8af07b0c67c89a50d7997b116199068271ed99b55bc530", size = 144296, upload-time = "2025-07-31T19:33:44.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ab/4c56a010a1104ace6cb780a448e02aead5effd2d31c83f8c3e8eb48bab16/ttkbootstrap-1.14.2-py3-none-any.whl", hash = "sha256:a0c2dfc952941ae9eb3e8b976d0e4361405ca578d3639d871e589c9fe053388a", size = 152734, upload-time = "2025-07-31T19:33:42.638Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user