сборка для Ильи
This commit is contained in:
commit
59ce64be5c
18 files changed
+2400
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
# MikroTik Credentials
|
||||||
|
MT_SECURE=false
|
||||||
|
MT_PORT=8728
|
||||||
|
|
||||||
|
# App Settings
|
||||||
|
APP_SECRET_KEY=generate_a_random_string_here
|
||||||
|
ADMIN_PASS=your_strong_admin_password_here
|
||||||
|
your_secret_password
|
||||||
|
|
||||||
|
# Overrides (optional)
|
||||||
|
# MT_HOST=10.0.0.1
|
||||||
|
# MT_SECURE=true
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# MikroTik Proxy API Dashboard
|
||||||
|
|
||||||
|
A modern, secure web interface and proxy API for managing MikroTik Firewall Address Lists. Designed for remote control with IP identification and administrative management.
|
||||||
|
|
||||||
|
## 🌟 Key Features
|
||||||
|
|
||||||
|
- **JWT Authentication**: Secure administrative access with username/password hashing.
|
||||||
|
- **Structured List Management**: Define named lists (e.g., `trusted`, `guests`) and assign IPs via a strict interface.
|
||||||
|
- **Client Dashboard**: Pure IP-based identification for end-users to toggle their own access status.
|
||||||
|
- **Glassmorphism UI**: Premium, responsive interface using modern CSS and the "Outfit" typography.
|
||||||
|
- **SQLite Integration**: Local source of truth for synchronization consistency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗 Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Client[End-User Browser] -->|IP Auth| API[Flask Proxy]
|
||||||
|
Admin[Admin Browser] -->|JWT Auth| API
|
||||||
|
API --> DB[(SQLite DB)]
|
||||||
|
API --> MT[MikroTik RouterOS]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and fill in your details:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `MT_HOST` | MikroTik Router IP/Hostname |
|
||||||
|
| `MT_USER` | MikroTik API User |
|
||||||
|
| `MT_PASS` | MikroTik API Password |
|
||||||
|
| `APP_SECRET_KEY` | Key for signing JWT tokens |
|
||||||
|
| `ADMIN_PASS` | Initial password for the `admin` user |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Installation & Deployment
|
||||||
|
|
||||||
|
### 1. Requirements
|
||||||
|
- Python 3.8+
|
||||||
|
- `pip install -r requirements.txt`
|
||||||
|
|
||||||
|
### 2. Running as a Service
|
||||||
|
|
||||||
|
#### **Alpine Linux (OpenRC)**
|
||||||
|
Create `/etc/init.d/mt-proxy`:
|
||||||
|
```bash
|
||||||
|
#!/sbin/openrc-run
|
||||||
|
description="MikroTik Proxy API"
|
||||||
|
command="/usr/bin/python3"
|
||||||
|
command_args="/path/to/app/main.py"
|
||||||
|
command_background="yes"
|
||||||
|
pidfile="/run/mt-proxy.pid"
|
||||||
|
directory="/path/to/app"
|
||||||
|
environment="PYTHONPATH=/path/to/app"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`chmod +x /etc/init.d/mt-proxy && rc-update add mt-proxy default && rc-service mt-proxy start`
|
||||||
|
|
||||||
|
#### **Debian / Ubuntu (systemd)**
|
||||||
|
Create `/etc/systemd/system/mt-proxy.service`:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=MikroTik Proxy API
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/var/www/mt-proxy
|
||||||
|
Environment="PATH=/var/www/mt-proxy/venv/bin"
|
||||||
|
ExecStart=/var/www/mt-proxy/venv/bin/python main.py
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
`systemctl enable mt-proxy && systemctl start mt-proxy`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Nginx Reverse Proxy
|
||||||
|
|
||||||
|
To access the API on port 80/443:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name proxy.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 API Documentation
|
||||||
|
|
||||||
|
### 1. Authentication
|
||||||
|
*Used to obtain a JWT token for administrative actions.*
|
||||||
|
|
||||||
|
**POST** `/api/v1/auth/login`
|
||||||
|
- **Body**: `{"username": "admin", "password": "..."}`
|
||||||
|
- **Success (200)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "token": "ey..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Error (401)**: `{"success": false, "error": "Invalid credentials"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Administrative API (Protected)
|
||||||
|
*Required Header: `Authorization: Bearer <token>`*
|
||||||
|
|
||||||
|
#### **Address Assignments**
|
||||||
|
- **GET** `/api/v1/addresses`: List all IP assignments.
|
||||||
|
- **Success**: `{"success": true, "data": [{"id": 1, "ip": "1.2.3.4", "list_name": "trusted", "enabled": true, ...}]}`
|
||||||
|
- **POST** `/api/v1/addresses`: Assign IP to a list.
|
||||||
|
- **Body**: `{"address": "1.2.3.4", "list": "trusted", "comment": "Work PC"}`
|
||||||
|
- **Success (201)**: `{"success": true, "data": {"mikrotik_id": "*1A"}}`
|
||||||
|
- **DELETE** `/api/v1/addresses/<id>`: Remove assignment.
|
||||||
|
- **PUT** `/api/v1/addresses/<id>/enable`: Activate IP.
|
||||||
|
- **PUT** `/api/v1/addresses/<id>/disable`: Deactivate IP.
|
||||||
|
|
||||||
|
#### **Named Lists Management**
|
||||||
|
- **GET** `/api/v1/named-lists`: List all managed address lists.
|
||||||
|
- **Success**: `{"success": true, "data": [{"id": 1, "name": "trusted"}, {"id": 2, "name": "guests"}]}`
|
||||||
|
- **POST** `/api/v1/named-lists`: Create a new list name.
|
||||||
|
- **Body**: `{"name": "office_vpn"}`
|
||||||
|
- **DELETE** `/api/v1/named-lists/<id>`: Remove list definition.
|
||||||
|
- **Error (400)**: `{"success": false, "error": "Cannot delete: 5 addresses are still in this list"}`
|
||||||
|
|
||||||
|
#### **Profile Management**
|
||||||
|
- **POST** `/api/v1/admin/profile`: Update your credentials.
|
||||||
|
- **Body**: `{"username": "new_admin", "password": "new_password"}` (password optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Client Dashboard API (No Auth)
|
||||||
|
*Determines status based on the calling user's public IP.*
|
||||||
|
|
||||||
|
#### **Check Status**
|
||||||
|
- **GET** `/api/v1/client/status`
|
||||||
|
- **Success (200)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"ip": "82.202.10.5",
|
||||||
|
"enabled": true,
|
||||||
|
"list": "trusted",
|
||||||
|
"comment": "Home Office"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Forbidden (403)**: `{"success": false, "error": "Access denied for IP 8.8.8.8"}` (IP not in any whitelist)
|
||||||
|
|
||||||
|
#### **Toggle Status**
|
||||||
|
- **PUT** `/api/v1/client/toggle`
|
||||||
|
- **Success**: `{"success": true, "data": {"new_state": false}}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Architecture & Sync Details
|
||||||
|
|
||||||
|
- **Master Database**: SQLite is the source of truth. Changes are committed to DB *before* calling the MikroTik API to ensure consistency.
|
||||||
|
- **MikroTik Sync**: The proxy uses the native MikroTik API (`/ip/firewall/address-list/set`) using internal IDs for high performance.
|
||||||
|
- **Security**: Admin passwords are salted and hashed using PBKDF2. JWT tokens expire after 24 hours.
|
||||||
|
|
||||||
|
## 📜 Maintenance
|
||||||
|
|
||||||
|
- **Primary Admin**: Created automatically on first run using values from `.env`.
|
||||||
|
- **Logs**: Standard output. On Alpine/Debian, use `logread` or `journalctl -u mt-proxy`.
|
||||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"mikrotik": {
|
||||||
|
"host": "192.168.5.253",
|
||||||
|
"username": "apiuser",
|
||||||
|
"password": "apipass",
|
||||||
|
"port": 8728,
|
||||||
|
"port_ssl": 8729,
|
||||||
|
"use_ssl": false
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"port": 5001,
|
||||||
|
"debug": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), 'app_data.db')
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS address_list_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip TEXT UNIQUE NOT NULL,
|
||||||
|
list_name TEXT NOT NULL,
|
||||||
|
comment TEXT,
|
||||||
|
mikrotik_id TEXT,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
last_sync DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS named_lists (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Миграция: добавляем существующие уникальные имена списков в новую таблицу
|
||||||
|
cursor.execute('SELECT DISTINCT list_name FROM address_list_items')
|
||||||
|
existing_lists = cursor.fetchall()
|
||||||
|
for row in existing_lists:
|
||||||
|
cursor.execute('INSERT OR IGNORE INTO named_lists (name) VALUES (?)', (row[0],))
|
||||||
|
|
||||||
|
# Если список пуст, создаем дефолтный
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM named_lists')
|
||||||
|
if cursor.fetchone()[0] == 0:
|
||||||
|
cursor.execute('INSERT INTO named_lists (name) VALUES (?)', ('trusted',))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def get_item_by_ip(ip):
|
||||||
|
conn = get_db_connection()
|
||||||
|
item = conn.execute('SELECT * FROM address_list_items WHERE ip = ?', (ip,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def get_all_items(list_name=None):
|
||||||
|
conn = get_db_connection()
|
||||||
|
if list_name:
|
||||||
|
items = conn.execute('SELECT * FROM address_list_items WHERE list_name = ?', (list_name,)).fetchall()
|
||||||
|
else:
|
||||||
|
items = conn.execute('SELECT * FROM address_list_items').fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(i) for i in items]
|
||||||
|
|
||||||
|
def add_item(ip, list_name, comment, mikrotik_id, enabled=True):
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'INSERT INTO address_list_items (ip, list_name, comment, mikrotik_id, enabled) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
(ip, list_name, comment, mikrotik_id, enabled)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True, cursor.lastrowid
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False, "IP already exists"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_item_status(ip, enabled):
|
||||||
|
conn = get_db_connection()
|
||||||
|
conn.execute(
|
||||||
|
'UPDATE address_list_items SET enabled = ?, last_sync = CURRENT_TIMESTAMP WHERE ip = ?',
|
||||||
|
(enabled, ip)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_mikrotik_id(ip, mikrotik_id):
|
||||||
|
conn = get_db_connection()
|
||||||
|
conn.execute(
|
||||||
|
'UPDATE address_list_items SET mikrotik_id = ? WHERE ip = ?',
|
||||||
|
(mikrotik_id, ip)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_item_by_ip(ip):
|
||||||
|
conn = get_db_connection()
|
||||||
|
conn.execute('DELETE FROM address_list_items WHERE ip = ?', (ip,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_item_by_id(db_id):
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
item = conn.execute('SELECT ip, mikrotik_id FROM address_list_items WHERE id = ?', (db_id,)).fetchone()
|
||||||
|
if item:
|
||||||
|
conn.execute('DELETE FROM address_list_items WHERE id = ?', (db_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return dict(item)
|
||||||
|
conn.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_item_by_id(db_id):
|
||||||
|
conn = get_db_connection()
|
||||||
|
item = conn.execute('SELECT * FROM address_list_items WHERE id = ?', (db_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def get_unique_lists():
|
||||||
|
conn = get_db_connection()
|
||||||
|
lists = conn.execute('SELECT name FROM named_lists ORDER BY name').fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [row['name'] for row in lists]
|
||||||
|
|
||||||
|
def get_named_lists_detailed():
|
||||||
|
conn = get_db_connection()
|
||||||
|
lists = conn.execute('SELECT * FROM named_lists ORDER BY name').fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(row) for row in lists]
|
||||||
|
|
||||||
|
def add_named_list(name):
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
conn.execute('INSERT INTO named_lists (name) VALUES (?)', (name,))
|
||||||
|
conn.commit()
|
||||||
|
return True, ""
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False, "List already exists"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_named_list(list_id):
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
# Проверяем, используются ли записи из этого списка
|
||||||
|
list_name_row = conn.execute('SELECT name FROM named_lists WHERE id = ?', (list_id,)).fetchone()
|
||||||
|
if not list_name_row:
|
||||||
|
return False, "Not found"
|
||||||
|
|
||||||
|
usage_count = conn.execute('SELECT COUNT(*) FROM address_list_items WHERE list_name = ?', (list_name_row['name'],)).fetchone()[0]
|
||||||
|
if usage_count > 0:
|
||||||
|
return False, f"Cannot delete: {usage_count} addresses are still in this list"
|
||||||
|
|
||||||
|
conn.execute('DELETE FROM named_lists WHERE id = ?', (list_id,))
|
||||||
|
conn.commit()
|
||||||
|
return True, ""
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# --- Admin Operations ---
|
||||||
|
|
||||||
|
def get_admin(username):
|
||||||
|
conn = get_db_connection()
|
||||||
|
admin = conn.execute('SELECT * FROM admins WHERE username = ?', (username,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return admin
|
||||||
|
|
||||||
|
def add_admin(username, password_hash):
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
conn.execute('INSERT INTO admins (username, password_hash) VALUES (?, ?)', (username, password_hash))
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_admin(old_username, new_username, new_password_hash):
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
'UPDATE admins SET username = ?, password_hash = ? WHERE username = ?',
|
||||||
|
(new_username, new_password_hash, old_username)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_db()
|
||||||
|
print("Database initialized.")
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import logging
|
||||||
|
from flask import Flask, request, jsonify, g, render_template
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
import database
|
||||||
|
import jwt
|
||||||
|
import datetime
|
||||||
|
from functools import wraps
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
# Загрузка .env файла для безопасности
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Инициализация БД при запуске
|
||||||
|
database.init_db()
|
||||||
|
|
||||||
|
# --- Настройки безопасности ---
|
||||||
|
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'super-secret-key-change-me')
|
||||||
|
DEFAULT_ADMIN = os.environ.get('MT_USER', 'admin') # Используем MT_USER как дефолтный логин
|
||||||
|
DEFAULT_PASS = os.environ.get('ADMIN_PASS', 'admin123')
|
||||||
|
|
||||||
|
def ensure_admin_exists():
|
||||||
|
"""Создает дефолтного админа, если таблица пуста"""
|
||||||
|
import sqlite3
|
||||||
|
conn = database.get_db_connection()
|
||||||
|
count = conn.execute('SELECT COUNT(*) FROM admins').fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
hash_pass = generate_password_hash(DEFAULT_PASS)
|
||||||
|
database.add_admin(DEFAULT_ADMIN, hash_pass)
|
||||||
|
logging.info(f"Default admin created: {DEFAULT_ADMIN}")
|
||||||
|
|
||||||
|
ensure_admin_exists()
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# --- Загрузка конфигурации ---
|
||||||
|
CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.json')
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""Загрузка настроек из config.json и переменных окружения"""
|
||||||
|
config = {
|
||||||
|
"mikrotik": {
|
||||||
|
"host": "10.0.0.1",
|
||||||
|
"username": "admin",
|
||||||
|
"password": "password",
|
||||||
|
"port": 8728,
|
||||||
|
"port_ssl": 8729,
|
||||||
|
"use_ssl": False
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"port": 5000,
|
||||||
|
"debug": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Загрузка из config.json
|
||||||
|
if os.path.exists(CONFIG_PATH):
|
||||||
|
try:
|
||||||
|
with open(CONFIG_PATH, 'r') as f:
|
||||||
|
user_config = json.load(f)
|
||||||
|
if 'mikrotik' in user_config:
|
||||||
|
config['mikrotik'].update(user_config['mikrotik'])
|
||||||
|
if 'api' in user_config:
|
||||||
|
config['api'].update(user_config['api'])
|
||||||
|
logger.info(f"Configuration loaded from {CONFIG_PATH}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load {CONFIG_PATH}: {e}")
|
||||||
|
|
||||||
|
# 2. Приоритет переменных окружения
|
||||||
|
config['mikrotik']['host'] = os.environ.get('MT_HOST', config['mikrotik']['host'])
|
||||||
|
config['mikrotik']['username'] = os.environ.get('MT_USER', config['mikrotik']['username'])
|
||||||
|
config['mikrotik']['password'] = os.environ.get('MT_PASS', config['mikrotik']['password'])
|
||||||
|
config['mikrotik']['use_ssl'] = os.environ.get('MT_SECURE', str(config['mikrotik']['use_ssl'])).lower() == 'true'
|
||||||
|
|
||||||
|
if config['mikrotik']['use_ssl']:
|
||||||
|
config['mikrotik']['port_active'] = int(os.environ.get('MT_PORT', config['mikrotik']['port_ssl']))
|
||||||
|
else:
|
||||||
|
config['mikrotik']['port_active'] = int(os.environ.get('MT_PORT', config['mikrotik']['port']))
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
APP_CONFIG = load_config()
|
||||||
|
|
||||||
|
# --- Настройки безопасности ---
|
||||||
|
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'super-secret-key-change-me')
|
||||||
|
|
||||||
|
# --- Вспомогательные функции ---
|
||||||
|
def get_client_ip():
|
||||||
|
"""Определение IP адреса клиента с учетом прокси"""
|
||||||
|
if request.headers.get('X-Forwarded-For'):
|
||||||
|
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||||
|
return request.remote_addr
|
||||||
|
|
||||||
|
class MikroTikAPIError(Exception):
|
||||||
|
"""Базовое исключение для MikroTik API"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MikroTikConnectionError(MikroTikAPIError):
|
||||||
|
"""Ошибка при установке соединения"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MikroTikAPI:
|
||||||
|
"""Класс для взаимодействия с MikroTik RouterOS API"""
|
||||||
|
|
||||||
|
def __init__(self, host, username, password, port=8728, secure=False, timeout=10):
|
||||||
|
self.host = host
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.port = port
|
||||||
|
self.secure = secure
|
||||||
|
self.timeout = timeout
|
||||||
|
self.connection = None
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(self.timeout)
|
||||||
|
if self.secure:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
self.connection = context.wrap_socket(sock, server_hostname=self.host)
|
||||||
|
else:
|
||||||
|
self.connection = sock
|
||||||
|
self.connection.connect((self.host, self.port))
|
||||||
|
logger.info(f"Connected to {self.host}:{self.port}")
|
||||||
|
result = self._talk(["/login", f"=name={self.username}", f"=password={self.password}"])
|
||||||
|
for reply, attrs in result:
|
||||||
|
if reply == '!trap':
|
||||||
|
error_msg = attrs.get('=message', 'Unknown login error')
|
||||||
|
raise MikroTikConnectionError(f"Login failed: {error_msg}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Connection error: {e}")
|
||||||
|
raise MikroTikConnectionError(str(e))
|
||||||
|
|
||||||
|
def _write_len(self, length):
|
||||||
|
if length < 0x80: self.connection.sendall(length.to_bytes(1, 'big'))
|
||||||
|
elif length < 0x4000: self.connection.sendall((length | 0x8000).to_bytes(2, 'big'))
|
||||||
|
elif length < 0x200000: self.connection.sendall((length | 0xC00000).to_bytes(3, 'big'))
|
||||||
|
elif length < 0x10000000: self.connection.sendall((length | 0xE0000000).to_bytes(4, 'big'))
|
||||||
|
else:
|
||||||
|
self.connection.sendall((0xF0).to_bytes(1, 'big'))
|
||||||
|
self.connection.sendall(length.to_bytes(4, 'big'))
|
||||||
|
|
||||||
|
def _read_len(self):
|
||||||
|
byte = self.connection.recv(1)
|
||||||
|
if not byte: return 0
|
||||||
|
c = byte[0]
|
||||||
|
if (c & 0x80) == 0x00: return c
|
||||||
|
elif (c & 0xC0) == 0x80: return ((c & ~0xC0) << 8) + self.connection.recv(1)[0]
|
||||||
|
elif (c & 0xE0) == 0xC0: return ((c & ~0xE0) << 16) + int.from_bytes(self.connection.recv(2), 'big')
|
||||||
|
elif (c & 0xF0) == 0xE0: return ((c & ~0xF0) << 24) + int.from_bytes(self.connection.recv(3), 'big')
|
||||||
|
elif (c & 0xF8) == 0xF0: return int.from_bytes(self.connection.recv(4), 'big')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _write_word(self, word):
|
||||||
|
encoded = word.encode('utf-8')
|
||||||
|
self._write_len(len(encoded))
|
||||||
|
self.connection.sendall(encoded)
|
||||||
|
|
||||||
|
def _read_word(self):
|
||||||
|
length = self._read_len()
|
||||||
|
if length == 0: return ''
|
||||||
|
data = b''
|
||||||
|
while len(data) < length:
|
||||||
|
chunk = self.connection.recv(length - len(data))
|
||||||
|
if not chunk: break
|
||||||
|
data += chunk
|
||||||
|
return data.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
def _talk(self, words):
|
||||||
|
for word in words: self._write_word(word)
|
||||||
|
self._write_word('')
|
||||||
|
result = []
|
||||||
|
while True:
|
||||||
|
sentence = []
|
||||||
|
while True:
|
||||||
|
word = self._read_word()
|
||||||
|
if word == '': break
|
||||||
|
sentence.append(word)
|
||||||
|
if not sentence: continue
|
||||||
|
reply = sentence[0]
|
||||||
|
attrs = {}
|
||||||
|
for word in sentence[1:]:
|
||||||
|
eq_pos = word.find('=', 1)
|
||||||
|
if eq_pos == -1: attrs[word.lstrip('=.')] = ''
|
||||||
|
else: attrs[word[1:eq_pos]] = word[eq_pos+1:]
|
||||||
|
result.append((reply, attrs))
|
||||||
|
if reply == '!done': break
|
||||||
|
if reply == '!trap': break
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_address_list(self, list_name=None):
|
||||||
|
cmd = ["/ip/firewall/address-list/print"]
|
||||||
|
if list_name: cmd.append(f"?list={list_name}")
|
||||||
|
result = self._talk(cmd)
|
||||||
|
return [{ 'id': a.get('.id'), 'address': a.get('address'), 'list': a.get('list'), 'disabled': a.get('disabled') == 'true' } for r, a in result if r == '!re']
|
||||||
|
|
||||||
|
def add_address(self, address, list_name, comment=""):
|
||||||
|
cmd = ["/ip/firewall/address-list/add", f"=address={address}", f"=list={list_name}"]
|
||||||
|
if comment: cmd.append(f"=comment={comment}")
|
||||||
|
result = self._talk(cmd)
|
||||||
|
for r, a in result:
|
||||||
|
if r == '!trap': return False, a.get('message', 'Unknown error')
|
||||||
|
if r == '!done': return True, a.get('ret')
|
||||||
|
return False, "Failed"
|
||||||
|
|
||||||
|
def set_address_state(self, address_id, disabled=False):
|
||||||
|
cmd = ["/ip/firewall/address-list/set", f"=.id={address_id}", f"=disabled={'yes' if disabled else 'no'}"]
|
||||||
|
result = self._talk(cmd)
|
||||||
|
for r, a in result:
|
||||||
|
if r == '!trap': return False, a.get('message', 'Unknown error')
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def remove_address(self, address_id):
|
||||||
|
cmd = ["/ip/firewall/address-list/remove", f"=.id={address_id}"]
|
||||||
|
result = self._talk(cmd)
|
||||||
|
for r, a in result:
|
||||||
|
if r == '!trap': return False, a.get('message', 'Unknown error')
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.connection:
|
||||||
|
try: self.connection.close()
|
||||||
|
except: pass
|
||||||
|
self.connection = None
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_mt_api():
|
||||||
|
mt = APP_CONFIG['mikrotik']
|
||||||
|
api = MikroTikAPI(mt['host'], mt['username'], mt['password'], port=mt['port_active'], secure=mt['use_ssl'])
|
||||||
|
try:
|
||||||
|
api.connect()
|
||||||
|
yield api
|
||||||
|
finally:
|
||||||
|
api.close()
|
||||||
|
|
||||||
|
def response_json(success, data=None, error=None, status=200):
|
||||||
|
resp = {"success": success}
|
||||||
|
if data is not None: resp["data"] = data
|
||||||
|
if error is not None: resp["error"] = error
|
||||||
|
return jsonify(resp), status
|
||||||
|
|
||||||
|
# --- Authentication Helpers ---
|
||||||
|
|
||||||
|
def token_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
token = None
|
||||||
|
if 'Authorization' in request.headers:
|
||||||
|
auth_header = request.headers['Authorization']
|
||||||
|
if auth_header.startswith('Bearer '):
|
||||||
|
token = auth_header.split(" ")[1]
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return response_json(False, error="Token is missing", status=401)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||||
|
g.admin_user = data['user']
|
||||||
|
except Exception:
|
||||||
|
return response_json(False, error="Token is invalid or expired", status=401)
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
@app.route('/api/v1/auth/login', methods=['POST'])
|
||||||
|
def login():
|
||||||
|
auth = request.json
|
||||||
|
username = auth.get('username')
|
||||||
|
password = auth.get('password')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return response_json(False, error="Username and Password required", status=400)
|
||||||
|
|
||||||
|
admin = database.get_admin(username)
|
||||||
|
if admin and check_password_hash(admin['password_hash'], password):
|
||||||
|
token = jwt.encode({
|
||||||
|
'user': username,
|
||||||
|
'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24)
|
||||||
|
}, SECRET_KEY)
|
||||||
|
return response_json(True, data={'token': token})
|
||||||
|
|
||||||
|
return response_json(False, error="Invalid credentials", status=401)
|
||||||
|
|
||||||
|
@app.route('/api/v1/admin/profile', methods=['POST'])
|
||||||
|
@token_required
|
||||||
|
def update_profile():
|
||||||
|
data = request.json
|
||||||
|
new_username = data.get('username')
|
||||||
|
new_password = data.get('password')
|
||||||
|
|
||||||
|
if not new_username:
|
||||||
|
return response_json(False, error="Username is required", status=400)
|
||||||
|
|
||||||
|
# Хешируем новый пароль, если он предоставлен
|
||||||
|
admin = database.get_admin(g.admin_user)
|
||||||
|
new_hash = admin['password_hash']
|
||||||
|
if new_password:
|
||||||
|
new_hash = generate_password_hash(new_password)
|
||||||
|
|
||||||
|
if database.update_admin(g.admin_user, new_username, new_hash):
|
||||||
|
# Если имя сменилось, нужно будет перелогиниться или выдать новый токен
|
||||||
|
return response_json(True, data={"message": "Profile updated successfully"})
|
||||||
|
return response_json(False, error="Failed to update profile", status=500)
|
||||||
|
|
||||||
|
@app.errorhandler(MikroTikAPIError)
|
||||||
|
def handle_api_error(e): return response_json(False, error=str(e), status=502)
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_generic_error(e):
|
||||||
|
if isinstance(e, HTTPException):
|
||||||
|
return response_json(False, error=e.description, status=e.code)
|
||||||
|
logger.exception("Unhandled server error")
|
||||||
|
return response_json(False, error="Internal Server Error", status=500)
|
||||||
|
|
||||||
|
@app.route('/api/v1/addresses', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def list_addresses():
|
||||||
|
return response_json(True, data=database.get_all_items(request.args.get('list')))
|
||||||
|
|
||||||
|
@app.route('/api/v1/lists', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def list_names():
|
||||||
|
# Возвращает простой список строк для выпадающих списков
|
||||||
|
return response_json(True, data=database.get_unique_lists())
|
||||||
|
|
||||||
|
@app.route('/api/v1/named-lists', methods=['GET'])
|
||||||
|
@token_required
|
||||||
|
def get_named_lists():
|
||||||
|
# Возвращает подробный список с ID для управления
|
||||||
|
return response_json(True, data=database.get_named_lists_detailed())
|
||||||
|
|
||||||
|
@app.route('/api/v1/named-lists', methods=['POST'])
|
||||||
|
@token_required
|
||||||
|
def create_named_list():
|
||||||
|
data = request.json
|
||||||
|
name = data.get('name')
|
||||||
|
if not name:
|
||||||
|
return response_json(False, error="Name is required", status=400)
|
||||||
|
|
||||||
|
success, error = database.add_named_list(name)
|
||||||
|
if success:
|
||||||
|
return response_json(True, status=201)
|
||||||
|
return response_json(False, error=error, status=400)
|
||||||
|
|
||||||
|
@app.route('/api/v1/named-lists/<int:list_id>', methods=['DELETE'])
|
||||||
|
@token_required
|
||||||
|
def delete_named_list(list_id):
|
||||||
|
success, error = database.delete_named_list(list_id)
|
||||||
|
if success:
|
||||||
|
return response_json(True)
|
||||||
|
return response_json(False, error=error, status=400)
|
||||||
|
|
||||||
|
@app.route('/api/v1/addresses', methods=['POST'])
|
||||||
|
@token_required
|
||||||
|
def create_address():
|
||||||
|
req = request.json or {}
|
||||||
|
address, list_name, comment = req.get('address'), req.get('list', 'default'), req.get('comment', '')
|
||||||
|
if not address: return response_json(False, error="Address required", status=400)
|
||||||
|
with get_mt_api() as api:
|
||||||
|
success, res = api.add_address(address, list_name, comment)
|
||||||
|
if success:
|
||||||
|
database.add_item(address, list_name, comment, res)
|
||||||
|
return response_json(True, data={"mikrotik_id": res}, status=201)
|
||||||
|
return response_json(False, error=res, status=400)
|
||||||
|
|
||||||
|
@app.route('/api/v1/addresses/<int:db_id>', methods=['DELETE'])
|
||||||
|
@token_required
|
||||||
|
def delete_address(db_id):
|
||||||
|
item = database.delete_item_by_id(db_id)
|
||||||
|
if not item: return response_json(False, error="Not found", status=404)
|
||||||
|
with get_mt_api() as api:
|
||||||
|
api.remove_address(item['mikrotik_id'])
|
||||||
|
return response_json(True)
|
||||||
|
|
||||||
|
@app.route('/api/v1/addresses/<int:db_id>/enable', methods=['PUT'])
|
||||||
|
@token_required
|
||||||
|
def enable_address(db_id):
|
||||||
|
item = database.get_item_by_id(db_id)
|
||||||
|
if not item: return response_json(False, error="Not found", status=404)
|
||||||
|
with get_mt_api() as api:
|
||||||
|
success, error = api.set_address_state(item['mikrotik_id'], disabled=False)
|
||||||
|
if success:
|
||||||
|
database.update_item_status(item['ip'], enabled=True)
|
||||||
|
return response_json(True)
|
||||||
|
return response_json(False, error=error, status=400)
|
||||||
|
|
||||||
|
@app.route('/api/v1/addresses/<int:db_id>/disable', methods=['PUT'])
|
||||||
|
@token_required
|
||||||
|
def disable_address(db_id):
|
||||||
|
item = database.get_item_by_id(db_id)
|
||||||
|
if not item: return response_json(False, error="Not found", status=404)
|
||||||
|
with get_mt_api() as api:
|
||||||
|
success, error = api.set_address_state(item['mikrotik_id'], disabled=True)
|
||||||
|
if success:
|
||||||
|
database.update_item_status(item['ip'], enabled=False)
|
||||||
|
return response_json(True)
|
||||||
|
return response_json(False, error=error, status=400)
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Служит основной дашборд"""
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/admin')
|
||||||
|
def admin():
|
||||||
|
"""Служит админ-панель"""
|
||||||
|
return render_template('admin.html')
|
||||||
|
|
||||||
|
@app.route('/login')
|
||||||
|
def login_page():
|
||||||
|
"""Служит страницу входа"""
|
||||||
|
return render_template('login.html')
|
||||||
|
|
||||||
|
@app.route('/api/v1/client/status', methods=['GET'])
|
||||||
|
def client_status():
|
||||||
|
ip = get_client_ip()
|
||||||
|
item = database.get_item_by_ip(ip)
|
||||||
|
if not item: return response_json(False, error="Нет интеграции с сервисом", status=403)
|
||||||
|
return response_json(True, data={"ip": ip, "enabled": bool(item['enabled']), "list": item['list_name']})
|
||||||
|
|
||||||
|
@app.route('/api/v1/client/toggle', methods=['PUT'])
|
||||||
|
def client_toggle():
|
||||||
|
ip = get_client_ip()
|
||||||
|
item = database.get_item_by_ip(ip)
|
||||||
|
if not item: return response_json(False, error="Access denied", status=403)
|
||||||
|
new_state = not bool(item['enabled'])
|
||||||
|
with get_mt_api() as api:
|
||||||
|
success, error = api.set_address_state(item['mikrotik_id'], disabled=not new_state)
|
||||||
|
if success:
|
||||||
|
database.update_item_status(ip, enabled=new_state)
|
||||||
|
return response_json(True, data={"enabled": new_state})
|
||||||
|
return response_json(False, error=error, status=400)
|
||||||
|
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health(): return response_json(True, data={"status": "ok"})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=APP_CONFIG['api']['port'], debug=APP_CONFIG['api']['debug'])
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask>=2.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
PyJWT>=2.0.0
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/* admin.css - Updated to match index.html aesthetics */
|
||||||
|
:root {
|
||||||
|
--primary-gradient: linear-gradient(135deg, #1a2980 0%, #26d0ce 100%);
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.95);
|
||||||
|
--text-color: #333;
|
||||||
|
--heading-color: #2c3e50;
|
||||||
|
--success-color: #2ecc71;
|
||||||
|
--danger-color: #e74c3c;
|
||||||
|
--info-color: #3498db;
|
||||||
|
--radius-lg: 20px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--shadow-heavy: 0 15px 35px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-light: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Outfit', 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 30px 20px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto 40px auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h2 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
text-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Management Cards - Glassmorphism */
|
||||||
|
.management-card {
|
||||||
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-heavy);
|
||||||
|
padding: 40px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: var(--heading-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Cards */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-light);
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.inline-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)) auto;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 2px solid #eee;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background-color: white;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2395a5a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 15px center;
|
||||||
|
background-size: 18px;
|
||||||
|
padding-right: 45px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--info-color);
|
||||||
|
box-shadow: 0 0 0 4px rgba(52, 152, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Styles - Premium Look */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #fcfdfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons - Matching index.html */
|
||||||
|
.btn {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--info-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: rgba(46, 204, 113, 0.15);
|
||||||
|
color: #27ae60;
|
||||||
|
border: 1px solid #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background-color: rgba(231, 76, 60, 0.15);
|
||||||
|
color: #c0392b;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notification / Toast */
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 15px 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: var(--shadow-heavy);
|
||||||
|
display: none;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.inline-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Outfit', 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #1a2980 0%, #26d0ce 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: rgba(255, 255, 255, 0.75);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 50px 40px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-panel {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 25px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 25px;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-on {
|
||||||
|
background-color: rgba(46, 204, 113, 0.15);
|
||||||
|
color: #27ae60;
|
||||||
|
border: 2px solid #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-off {
|
||||||
|
background-color: rgba(231, 76, 60, 0.15);
|
||||||
|
color: #c0392b;
|
||||||
|
border: 2px solid #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-loading {
|
||||||
|
background-color: rgba(52, 152, 219, 0.15);
|
||||||
|
color: #2980b9;
|
||||||
|
border: 2px solid #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disabled {
|
||||||
|
background-color: rgba(149, 165, 166, 0.15);
|
||||||
|
color: #7f8c8d;
|
||||||
|
border: 2px solid #95a5a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator i {
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 18px 30px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 180px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(2px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-enabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-on {
|
||||||
|
background-color: #2ecc71;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(.btn-disabled) {
|
||||||
|
background-color: #27ae60;
|
||||||
|
box-shadow: 0 8px 20px rgba(46, 204, 113, 0.4);
|
||||||
|
transform: translateY(-3px) scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-off {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-off:hover:not(.btn-disabled) {
|
||||||
|
background-color: #c0392b;
|
||||||
|
box-shadow: 0 8px 20px rgba(231, 76, 60, 0.4);
|
||||||
|
transform: translateY(-3px) scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn i {
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
background-color: rgba(255, 255, 255, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: left;
|
||||||
|
border-left: 4px solid #3498db;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-left-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification h3 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification h3 i {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
border-left-color: rgba(231, 76, 60, 0.5);
|
||||||
|
background-color: rgba(231, 76, 60, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
border-left-color: rgba(46, 204, 113, 0.5);
|
||||||
|
background-color: rgba(46, 204, 113, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning {
|
||||||
|
border-left-color: rgba(243, 156, 18, 0.5);
|
||||||
|
background-color: rgba(243, 156, 18, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 3px solid rgba(52, 152, 219, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #3498db;
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-info {
|
||||||
|
background-color: rgba(241, 248, 255, 0.6);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #4a5a6a;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-info strong {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-info ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-info li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 650px) {
|
||||||
|
.container {
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
// admin.js
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Проверка наличия токена перед загрузкой
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAddresses();
|
||||||
|
fetchNamedLists();
|
||||||
|
|
||||||
|
const addForm = document.getElementById('addAddressForm');
|
||||||
|
addForm.addEventListener('submit', handleAddAddress);
|
||||||
|
|
||||||
|
const addListForm = document.getElementById('addListForm');
|
||||||
|
addListForm.addEventListener('submit', handleAddList);
|
||||||
|
|
||||||
|
const filterInput = document.getElementById('tableFilter');
|
||||||
|
filterInput.addEventListener('input', handleFilter);
|
||||||
|
|
||||||
|
const profileForm = document.getElementById('profileForm');
|
||||||
|
profileForm.addEventListener('submit', handleProfileUpdate);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Хелпер для авторизованных запросов
|
||||||
|
async function authorizedFetch(url, options = {}) {
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
|
||||||
|
const defaultHeaders = {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...defaultHeaders,
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNamedLists() {
|
||||||
|
try {
|
||||||
|
const response = await authorizedFetch('/api/v1/named-lists');
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
renderListsTable(data.data);
|
||||||
|
populateListDropdown(data.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка при загрузке списков:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderListsTable(lists) {
|
||||||
|
const tbody = document.getElementById('listsTableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
lists.forEach(list => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${list.id}</td>
|
||||||
|
<td><strong>${list.name}</strong></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteNamedList(${list.id})" title="Удалить список">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateListDropdown(lists) {
|
||||||
|
const select = document.getElementById('inputList');
|
||||||
|
const currentValue = select.value;
|
||||||
|
|
||||||
|
select.innerHTML = '';
|
||||||
|
lists.forEach(list => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = list.name;
|
||||||
|
option.textContent = list.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentValue && lists.some(l => l.name === currentValue)) {
|
||||||
|
select.value = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddList(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = document.getElementById('newListName').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authorizedFetch('/api/v1/named-lists', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Список создан', 'success');
|
||||||
|
document.getElementById('addListForm').reset();
|
||||||
|
fetchNamedLists();
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + data.error, 'danger');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Сетевая ошибка', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNamedList(id) {
|
||||||
|
if (!confirm('Удалить этот список? Это возможно только если в нем нет адресов.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authorizedFetch(`/api/v1/named-lists/${id}`, { method: 'DELETE' });
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Список удален', 'success');
|
||||||
|
fetchNamedLists();
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + data.error, 'danger');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Сетевая ошибка', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAddresses() {
|
||||||
|
try {
|
||||||
|
const response = await authorizedFetch('/api/v1/addresses');
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
renderTable(data.data);
|
||||||
|
updateStats(data.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Ошибка при загрузке данных', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(addresses) {
|
||||||
|
const tbody = document.getElementById('addressesTableBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
addresses.forEach(item => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${item.id}</td>
|
||||||
|
<td><strong>${item.ip}</strong></td>
|
||||||
|
<td><span class="badge" style="background: #eee;">${item.list_name}</span></td>
|
||||||
|
<td>${item.comment || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge ${item.enabled ? 'badge-success' : 'badge-danger'}">
|
||||||
|
${item.enabled ? 'Активен' : 'Отключен'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="toggleAddress(${item.id}, ${item.enabled})" title="${item.enabled ? 'Отключить' : 'Включить'}">
|
||||||
|
<i class="fas fa-power-off"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteAddress(${item.id})" title="Удалить">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(addresses) {
|
||||||
|
document.getElementById('statTotal').textContent = addresses.length;
|
||||||
|
document.getElementById('statActive').textContent = addresses.filter(a => a.enabled).length;
|
||||||
|
document.getElementById('statInactive').textContent = addresses.filter(a => !a.enabled).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddAddress(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const address = document.getElementById('inputAddress').value;
|
||||||
|
const list = document.getElementById('inputList').value;
|
||||||
|
const comment = document.getElementById('inputComment').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authorizedFetch('/api/v1/addresses', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ address, list, comment })
|
||||||
|
});
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Адрес успешно добавлен', 'success');
|
||||||
|
document.getElementById('addAddressForm').reset();
|
||||||
|
fetchAddresses();
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + data.error, 'danger');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Сетевая ошибка', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAddress(id, currentEnabled) {
|
||||||
|
const action = currentEnabled ? 'disable' : 'enable';
|
||||||
|
try {
|
||||||
|
const response = await authorizedFetch(`/api/v1/addresses/${id}/${action}`, { method: 'PUT' });
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast(`Статус успешно изменен`, 'success');
|
||||||
|
fetchAddresses();
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + data.error, 'danger');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Сетевая ошибка', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAddress(id) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить этот адрес?')) return;
|
||||||
|
try {
|
||||||
|
const response = await authorizedFetch(`/api/v1/addresses/${id}`, { method: 'DELETE' });
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Запись удалена', 'success');
|
||||||
|
fetchAddresses();
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка при удалении', 'danger');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Сетевая ошибка', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProfileUpdate(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = document.getElementById('profileUsername').value;
|
||||||
|
const password = document.getElementById('profilePassword').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authorizedFetch('/api/v1/admin/profile', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Профиль успешно обновлен. Если вы сменили имя, потребуется перелогиниться.', 'success');
|
||||||
|
document.getElementById('profilePassword').value = '';
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + data.error, 'danger');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Сетевая ошибка', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilter(e) {
|
||||||
|
const text = e.target.value.toLowerCase();
|
||||||
|
const rows = document.querySelectorAll('#addressesTableBody tr');
|
||||||
|
rows.forEach(row => {
|
||||||
|
row.style.display = row.textContent.toLowerCase().includes(text) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type) {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.backgroundColor = type === 'success' ? '#2ecc71' : '#e74c3c';
|
||||||
|
toast.style.display = 'block';
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавим функцию выхода
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// Элементы DOM
|
||||||
|
const statusIndicator = document.getElementById('statusIndicator');
|
||||||
|
const statusInfo = document.getElementById('statusInfo');
|
||||||
|
const btnOn = document.getElementById('btnOn');
|
||||||
|
const btnOff = document.getElementById('btnOff');
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
const notificationMessage = document.getElementById('notificationMessage');
|
||||||
|
|
||||||
|
// Конфигурация API
|
||||||
|
const API_BASE_URL = window.location.origin;
|
||||||
|
const API_STATUS_ENDPOINT = `${API_BASE_URL}/api/v1/client/status`;
|
||||||
|
const API_TOGGLE_ENDPOINT = `${API_BASE_URL}/api/v1/client/toggle`;
|
||||||
|
|
||||||
|
// Переменные состояния
|
||||||
|
let currentStatus = null;
|
||||||
|
let isRequestInProgress = false;
|
||||||
|
let hasIntegration = false;
|
||||||
|
|
||||||
|
// Инициализация при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
loadInitialStatus();
|
||||||
|
|
||||||
|
btnOn.addEventListener('click', () => sendCommand(true));
|
||||||
|
btnOff.addEventListener('click', () => sendCommand(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция загрузки начального статуса
|
||||||
|
async function loadInitialStatus() {
|
||||||
|
try {
|
||||||
|
updateUIForLoading();
|
||||||
|
|
||||||
|
const response = await fetch(API_STATUS_ENDPOINT);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
hasIntegration = true;
|
||||||
|
currentStatus = data.data.enabled;
|
||||||
|
updateUIForStatus(currentStatus);
|
||||||
|
showNotification(`Доступ разрешен. Ваш IP: ${data.data.ip}.`, 'success', true);
|
||||||
|
} else {
|
||||||
|
hasIntegration = false;
|
||||||
|
updateUIForNoIntegration(data.error || 'Нет интеграции с сервисом');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при загрузке статуса:', error);
|
||||||
|
updateUIForError('Ошибка соединения с сервером API');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция отправки команды на сервер
|
||||||
|
async function sendCommand(enabled) {
|
||||||
|
if (!hasIntegration || isRequestInProgress) return;
|
||||||
|
|
||||||
|
// Прерываем, если запрашиваемое состояние уже активно
|
||||||
|
if (enabled === currentStatus) return;
|
||||||
|
|
||||||
|
isRequestInProgress = true;
|
||||||
|
const action = enabled ? 'включения' : 'выключения';
|
||||||
|
|
||||||
|
updateUIForSendingCommand(enabled);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_TOGGLE_ENDPOINT, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
currentStatus = data.data.enabled;
|
||||||
|
updateUIForStatus(currentStatus);
|
||||||
|
showNotification(`Команда ${action} успешно выполнена.`, 'success');
|
||||||
|
provideVisualFeedback(currentStatus);
|
||||||
|
} else {
|
||||||
|
showNotification(`Ошибка: ${data.error}`, 'error');
|
||||||
|
updateUIForStatus(currentStatus);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при отправке команды:', error);
|
||||||
|
showNotification('Ошибка сети при отправке команды', 'error');
|
||||||
|
updateUIForStatus(currentStatus);
|
||||||
|
} finally {
|
||||||
|
isRequestInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление UI при загрузке
|
||||||
|
function updateUIForLoading() {
|
||||||
|
statusIndicator.className = 'status-indicator status-loading';
|
||||||
|
statusIndicator.innerHTML = '<i class="fas fa-sync-alt fa-spin"></i> Загрузка...';
|
||||||
|
statusInfo.textContent = 'Получение текущего состояния с сервера...';
|
||||||
|
|
||||||
|
btnOn.classList.add('btn-disabled');
|
||||||
|
btnOff.classList.add('btn-disabled');
|
||||||
|
btnOn.disabled = true;
|
||||||
|
btnOff.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление UI на основе текущего статуса
|
||||||
|
function updateUIForStatus(enabled) {
|
||||||
|
if (enabled) {
|
||||||
|
statusIndicator.className = 'status-indicator status-on';
|
||||||
|
statusIndicator.innerHTML = '<i class="fas fa-check-circle"></i> Включено';
|
||||||
|
statusInfo.textContent = 'Устройство активно и работает в нормальном режиме';
|
||||||
|
|
||||||
|
btnOn.classList.add('btn-disabled');
|
||||||
|
btnOn.disabled = true;
|
||||||
|
|
||||||
|
btnOff.classList.remove('btn-disabled');
|
||||||
|
btnOff.disabled = false;
|
||||||
|
} else {
|
||||||
|
statusIndicator.className = 'status-indicator status-off';
|
||||||
|
statusIndicator.innerHTML = '<i class="fas fa-power-off"></i> Выключено';
|
||||||
|
statusInfo.textContent = 'Устройство отключено и не выполняет свои функции';
|
||||||
|
|
||||||
|
btnOff.classList.add('btn-disabled');
|
||||||
|
btnOff.disabled = true;
|
||||||
|
|
||||||
|
btnOn.classList.remove('btn-disabled');
|
||||||
|
btnOn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление UI при отсутствии интеграции
|
||||||
|
function updateUIForNoIntegration(message) {
|
||||||
|
statusIndicator.className = 'status-indicator status-disabled';
|
||||||
|
statusIndicator.innerHTML = '<i class="fas fa-ban"></i> Нет доступа';
|
||||||
|
statusInfo.textContent = 'Ваш IP-адрес не найден в списке доверенных адресов';
|
||||||
|
btnOn.classList.add('btn-disabled');
|
||||||
|
btnOff.classList.add('btn-disabled');
|
||||||
|
btnOn.disabled = true;
|
||||||
|
btnOff.disabled = true;
|
||||||
|
showNotification(message, 'error', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление UI при ошибке
|
||||||
|
function updateUIForError(errorMessage) {
|
||||||
|
statusIndicator.className = 'status-indicator status-disabled';
|
||||||
|
statusIndicator.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Ошибка';
|
||||||
|
statusInfo.textContent = 'Не удалось получить информацию о статусе устройства';
|
||||||
|
btnOn.classList.add('btn-disabled');
|
||||||
|
btnOff.classList.add('btn-disabled');
|
||||||
|
btnOn.disabled = true;
|
||||||
|
btnOff.disabled = true;
|
||||||
|
showNotification(errorMessage, 'error', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление UI при отправке команды
|
||||||
|
function updateUIForSendingCommand(enabled) {
|
||||||
|
const action = enabled ? 'включения' : 'выключения';
|
||||||
|
statusIndicator.className = 'status-indicator status-loading';
|
||||||
|
statusIndicator.innerHTML = '<i class="fas fa-sync-alt fa-spin"></i> Отправка...';
|
||||||
|
statusInfo.textContent = `Отправка команды ${action} на сервер...`;
|
||||||
|
btnOn.classList.add('btn-disabled');
|
||||||
|
btnOff.classList.add('btn-disabled');
|
||||||
|
btnOn.disabled = true;
|
||||||
|
btnOff.disabled = true;
|
||||||
|
showNotification(`Отправка команды ${action}...`, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переменные для управления уведомлениями
|
||||||
|
let baseNotificationContent = null;
|
||||||
|
let notificationTimeout = null;
|
||||||
|
|
||||||
|
// Показать уведомление
|
||||||
|
function showNotification(message, type = 'info', isPersistent = false) {
|
||||||
|
if (notificationTimeout) {
|
||||||
|
clearTimeout(notificationTimeout);
|
||||||
|
notificationTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.className = 'notification';
|
||||||
|
let icon = 'fa-info-circle';
|
||||||
|
let title = 'Информация:';
|
||||||
|
|
||||||
|
if (type === 'error') {
|
||||||
|
notification.classList.add('notification-error');
|
||||||
|
icon = 'fa-exclamation-circle';
|
||||||
|
title = 'Ошибка:';
|
||||||
|
} else if (type === 'success') {
|
||||||
|
notification.classList.add('notification-success');
|
||||||
|
icon = 'fa-check-circle';
|
||||||
|
title = 'Успешно:';
|
||||||
|
} else if (type === 'warning') {
|
||||||
|
notification.classList.add('notification-warning');
|
||||||
|
icon = 'fa-sync-alt fa-spin';
|
||||||
|
title = 'Обработка:';
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = `<h3><i class="fas ${icon}"></i> ${title}</h3><div class="notification-message">${message}</div>`;
|
||||||
|
notification.innerHTML = content;
|
||||||
|
|
||||||
|
if (isPersistent) {
|
||||||
|
baseNotificationContent = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.style.opacity = '0';
|
||||||
|
notification.style.display = 'block';
|
||||||
|
|
||||||
|
// Плавное появление
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.transition = 'opacity 0.3s ease';
|
||||||
|
notification.style.opacity = '1';
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Авто-возврат к базовому уведомлению через 5 секунд (если это не само базовое и не "в обработке")
|
||||||
|
if (!isPersistent && baseNotificationContent && type !== 'warning') {
|
||||||
|
notificationTimeout = setTimeout(() => {
|
||||||
|
notification.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.className = 'notification';
|
||||||
|
notification.innerHTML = baseNotificationContent;
|
||||||
|
notification.style.opacity = '1';
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Визуальная обратная связь
|
||||||
|
function provideVisualFeedback(enabled) {
|
||||||
|
if (enabled) {
|
||||||
|
document.body.style.background = 'linear-gradient(135deg, #2ecc71 0%, #27ae60 100%)';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.style.background = 'linear-gradient(135deg, #1a2980 0%, #26d0ce 100%)';
|
||||||
|
}, 800);
|
||||||
|
} else {
|
||||||
|
document.body.style.background = 'linear-gradient(135deg, #e74c3c 0%, #c0392b 100%)';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.style.background = 'linear-gradient(135deg, #1a2980 0%, #26d0ce 100%)';
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дополнительная функция для ручного обновления статуса
|
||||||
|
function refreshStatus() {
|
||||||
|
if (!isRequestInProgress) {
|
||||||
|
loadInitialStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusIndicator.addEventListener('dblclick', refreshStatus);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// login.js
|
||||||
|
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const loginBtn = document.getElementById('loginBtn');
|
||||||
|
|
||||||
|
// UI Feedback
|
||||||
|
loginBtn.classList.add('btn-disabled');
|
||||||
|
loginBtn.innerHTML = '<i class="fas fa-sync-alt fa-spin"></i> Авторизация...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Сохраняем токен
|
||||||
|
localStorage.setItem('admin_token', data.data.token);
|
||||||
|
|
||||||
|
showStatus('Успешно! Вход в систему...', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/admin';
|
||||||
|
}, 800);
|
||||||
|
} else {
|
||||||
|
showStatus(data.error || 'Ошибка входа', 'error');
|
||||||
|
resetButton(loginBtn);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showStatus('Ошибка сети или сервера', 'error');
|
||||||
|
resetButton(loginBtn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetButton(btn) {
|
||||||
|
btn.classList.remove('btn-disabled');
|
||||||
|
btn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Войти в панель';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(message, type) {
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
notification.className = 'notification';
|
||||||
|
if (type === 'error') {
|
||||||
|
notification.classList.add('notification-error');
|
||||||
|
notification.innerHTML = `<h3><i class="fas fa-exclamation-circle"></i> Ошибка:</h3><div class="notification-message">${message}</div>`;
|
||||||
|
} else {
|
||||||
|
notification.classList.add('notification-success');
|
||||||
|
notification.innerHTML = `<h3><i class="fas fa-check-circle"></i> Успешно:</h3><div class="notification-message">${message}</div>`;
|
||||||
|
}
|
||||||
|
notification.style.display = 'block';
|
||||||
|
notification.style.opacity = '1';
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Админ-панель | MikroTik Proxy</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="admin-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h2><i class="fas fa-shield-alt"></i> MikroTik Proxy Admin</h2>
|
||||||
|
</div>
|
||||||
|
<div class="header-right" style="display: flex; gap: 10px;">
|
||||||
|
<a href="/" class="btn btn-primary"><i class="fas fa-home"></i> К дашборду</a>
|
||||||
|
<button onclick="logout()" class="btn btn-danger"><i class="fas fa-sign-out-alt"></i> Выход</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="admin-container">
|
||||||
|
<!-- Статистика -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Всего IP</h3>
|
||||||
|
<div id="statTotal" class="value">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Активных</h3>
|
||||||
|
<div id="statActive" class="value" style="color: var(--success-color)">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Отключенных</h3>
|
||||||
|
<div id="statInactive" class="value" style="color: var(--danger-color)">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма добавления -->
|
||||||
|
<div class="management-card">
|
||||||
|
<h3 class="card-title"><i class="fas fa-plus-circle"></i> Добавить новый адрес</h3>
|
||||||
|
<form id="addAddressForm" class="inline-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>IP Адрес</label>
|
||||||
|
<input type="text" id="inputAddress" placeholder="например, 192.168.1.50" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название списка</label>
|
||||||
|
<select id="inputList" required>
|
||||||
|
<!-- Опции загружаются через JS -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Комментарий</label>
|
||||||
|
<input type="text" id="inputComment" placeholder="например, Мой ПК">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success"><i class="fas fa-plus"></i> Добавить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Управление списками -->
|
||||||
|
<div class="management-card">
|
||||||
|
<h3 class="card-title"><i class="fas fa-tags"></i> Управление списками доступа</h3>
|
||||||
|
<p style="margin-bottom: 20px; color: #666; font-size: 0.9rem;">
|
||||||
|
Создавайте именованные списки доступа здесь. Вы сможете выбирать их при добавлении новых IP.
|
||||||
|
</p>
|
||||||
|
<form id="addListForm" class="inline-form" style="margin-bottom: 25px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Новое имя списка</label>
|
||||||
|
<input type="text" id="newListName" placeholder="например, guest_access" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success"><i class="fas fa-plus"></i> Создать список</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th style="width: 100px;">Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="listsTableBody">
|
||||||
|
<!-- Данные загружаются через JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Список адресов -->
|
||||||
|
<div class="management-card">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3 class="card-title" style="margin: 0;"><i class="fas fa-list"></i> Управление списком доступа</h3>
|
||||||
|
<div class="form-group" style="width: 250px;">
|
||||||
|
<input type="text" id="tableFilter" placeholder="Поиск по IP или списку...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="addressesTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>IP Адрес</th>
|
||||||
|
<th>Список</th>
|
||||||
|
<th>Комментарий</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th style="width: 150px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="addressesTableBody">
|
||||||
|
<!-- Данные загружаются через JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Настройки профиля -->
|
||||||
|
<div class="management-card">
|
||||||
|
<h3 class="card-title"><i class="fas fa-user-cog"></i> Настройки администратора</h3>
|
||||||
|
<p style="margin-bottom: 20px; color: #666; font-size: 0.9rem;">
|
||||||
|
Здесь вы можете изменить имя пользователя и пароль для входа в панель управления.
|
||||||
|
</p>
|
||||||
|
<form id="profileForm" class="inline-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Имя пользователя</label>
|
||||||
|
<input type="text" id="profileUsername" placeholder="admin" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Новый пароль (оставьте пустым для сохранения текущего)</label>
|
||||||
|
<input type="password" id="profilePassword" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Обновить профиль</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" class="notification"></div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Управление устройством</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1><i class="fas fa-sliders-h"></i> Управление устройством</h1>
|
||||||
|
<p class="subtitle">Удаленное управление с проверкой IP-адреса</p>
|
||||||
|
|
||||||
|
<div class="status-panel">
|
||||||
|
<div class="status-title">Текущий статус устройства:</div>
|
||||||
|
<div id="statusIndicator" class="status-indicator status-loading">
|
||||||
|
<i class="fas fa-sync-alt fa-spin"></i> Загрузка...
|
||||||
|
</div>
|
||||||
|
<div id="statusInfo" class="status-info">
|
||||||
|
Получение текущего состояния с сервера...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button id="btnOn" class="btn btn-on btn-disabled">
|
||||||
|
<i class="fas fa-play"></i> Включить
|
||||||
|
</button>
|
||||||
|
<button id="btnOff" class="btn btn-off btn-disabled">
|
||||||
|
<i class="fas fa-stop"></i> Выключить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notification" class="notification">
|
||||||
|
<h3><i class="fas fa-info-circle"></i> Информация:</h3>
|
||||||
|
<div id="notificationMessage" class="notification-message">
|
||||||
|
Система определяет ваш IP-адрес и проверяет доступ к управлению.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-info">
|
||||||
|
<strong>Как это работает:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Сервер автоматически определяет ваш IP-адрес при подключении</li>
|
||||||
|
<li>IP-адрес проверяется по списку доверенных адресов</li>
|
||||||
|
<li>При успешной проверке отображаются кнопки управления</li>
|
||||||
|
<li>При отсутствии интеграции показывается сообщение об ошибке</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Авторизация | MikroTik Proxy</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
<style>
|
||||||
|
.login-container {
|
||||||
|
max-width: 450px;
|
||||||
|
width: 90%;
|
||||||
|
padding: 60px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: rgba(52, 152, 219, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #3498db;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a5a6a;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-icon {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-icon i {
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #95a5a6;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-icon input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 15px 14px 45px;
|
||||||
|
border: 2px solid #edf2f7;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-icon input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 4px rgba(52, 152, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-icon input:focus+i {
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container login-container">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="login-icon">
|
||||||
|
<i class="fas fa-lock"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Безопасный вход</h1>
|
||||||
|
<p class="subtitle">Управление прокси-сервером MikroTik</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Имя пользователя</label>
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
<input type="text" id="username" placeholder="admin" required autofocus>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
<input type="password" id="password" placeholder="••••••••" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-on btn-login" id="loginBtn">
|
||||||
|
<i class="fas fa-sign-in-alt"></i> Войти в панель
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="notification" class="notification"></div>
|
||||||
|
|
||||||
|
<div class="footer-links">
|
||||||
|
<p><a href="/"><i class="fas fa-arrow-left"></i> На главную</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/login.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user