Initial YakPanel commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.iml
|
||||
.idea/
|
||||
idea/*
|
||||
.vscode/*
|
||||
14
NOTICE
Normal file
14
NOTICE
Normal file
@@ -0,0 +1,14 @@
|
||||
YakPanel
|
||||
Copyright (c) the YakPanel contributors.
|
||||
|
||||
This project is a rebranded derivative of YakPanel (宝塔/BaoTa panel software).
|
||||
The upstream project and its copyrights remain with the original YakPanel/BaoTa
|
||||
authors. This NOTICE does not remove or supersede their licenses in files where
|
||||
they appear.
|
||||
|
||||
YakPanel is an independent fork; it is not affiliated with, endorsed by, or
|
||||
maintained by the YakPanel project.
|
||||
|
||||
Product names, update endpoints, and User-Agent strings have been pointed at
|
||||
yakpanel.com placeholders. You must operate your own mirrors or services at
|
||||
those hosts for updates, plugins, and APIs to function.
|
||||
93
README.md
Normal file
93
README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
<div align="center">
|
||||
<img src="https://www.yakpanel.com/images/bt_logo.png" alt="YakPanel " width="270"/>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="https://forum.yakpanel.com/assets/logo-kr3kouky.png" alt="YakPanel " width="120"/>
|
||||
</div>
|
||||
<br/>
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/YakPanel/YakPanel)
|
||||
[](https://github.com/YakPanel/YakPanel)
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
<a href="https://www.yakpanel.com">Official</a> |
|
||||
<a href="https://doc.yakpanel.com/web/#/3?page_id=117">documentation</a> |
|
||||
<a href="https://demo.yakpanel.com/fdgi87jbn/">Demo</a> |
|
||||
</p>
|
||||
|
||||
## About YakPanel
|
||||
|
||||
**YakPanel is a simple but powerful hosting control panel**, it can manage the web server through web-based GUI(Graphical User Interface).
|
||||
|
||||
**Modern stack (FastAPI + React):** see [`YakPanel-server/`](YakPanel-server/) in this repository for the API and SPA that map to yakpanel.com product work.
|
||||
|
||||
**Filesystem renames on existing servers:** this tree expects panel code under `YakPanel/`, launcher scripts `Yak-Panel` and `Yak-Task`, and the task package in `YakTask/`. If you upgrade from an older layout (`BTPanel`, `BT-Panel`, `BT-Task`, `BTTask`), rename those paths on disk and adjust any custom scripts or systemd units that still reference the old names. ipsets were renamed from `yakpanel.ipv4.*` to `yakpanel.ipv4.*`—recreate or migrate rules as needed. New encrypted DB field markers use the `YP-0x:` prefix (existing `BT-0x:` values still decrypt). Panel PKCS#12 downloads use `ssl/yakpanel_root.pfx` (legacy `ssl/baota_root.pfx` is still recognized until renewed). OpenLiteSpeed stubs use `YP_SITENAME` / `YP_RUN_PATH` instead of `BT_SITENAME` / `BT_RUN_PATH`, and `YP_OLS_NAME` / `YP_EXTP_NAME` / `YP_PHP_V` / `YP_SSL_DOMAIN` instead of `BTSITENAME` / `BT_EXTP_NAME` / `BTPHPV` / `BTDOMAIN`, and hotlink rules use `YP_FILE_EXT` instead of `BTPFILE`, for **new** generated snippets. One-click deployment config patching accepts both `YP_DB_USERNAME|YP_DB_PASSWORD|YP_DB_NAME` and the legacy `BT_DB_*` names.
|
||||
|
||||
* **one-click function:** such as one-click install LNMP/LAMP developing environment and software.
|
||||
* **save the time:** Our main goal is helping users to save the time of deploying, thus users just focus on their own project that is fine.
|
||||
|
||||
## Demo
|
||||
|
||||
Demo:https://demo.yakpanel.com/fdgi87jbn/<br/>
|
||||
username: yakpanel<br/>
|
||||
password: yakpanel
|
||||
|
||||
<!--  -->
|
||||

|
||||
|
||||
## What can I do
|
||||
|
||||
YakPanel is a server management software that supports the Linux system.
|
||||
|
||||
It can easily manage the server through the Web terminal, improving the operation and maintenance efficiency.
|
||||
|
||||
## Installation
|
||||
|
||||
> Make sure it is a clean operating system, and have not installed Apache /Nginx/php/MySQL from other environments
|
||||
> YakPanel is developed based on Ubuntu 22+, it is strongly recommended to use Ubuntu 22+ linux distribution
|
||||
|
||||
Note, please execute the installation command with root authority
|
||||
|
||||
* Memory: 512M or more, 768M or more is recommended (Pure panel for about 60M of system memory)
|
||||
|
||||
* Hard disk: More than 100M available hard disk space (Pure panel for about 20M disk space)
|
||||
|
||||
* System: Ubuntu 22.04 24.04, Debian 11 12, CentOS 9, Rocky/AlmaLinux 8 9, to ensure that it is a clean operating system, there is no other environment with Apache/Nginx/php/MySQL installed (the existing environment can not be installed)
|
||||
|
||||
**YakPanel Installation Command**
|
||||
|
||||
`URL=https://www.yakpanel.com/script/install_6.0_en.sh && if [ -f /usr/bin/curl ];then curl -ksSO "$URL" ;else wget --no-check-certificate -O install_6.0_en.sh "$URL";fi;bash install_6.0_en.sh 66959f96`
|
||||
|
||||
**YakPanel Docker Deployment**
|
||||
|
||||
> The docker image is officially released by YakPanel
|
||||
|
||||
Maintained by: [YakPanel](https://www.yakpanel.com)
|
||||
|
||||
|
||||
|
||||
How to use
|
||||
|
||||
`$docker run -d -p 8886:8888 -p 22:21 -p 443:443 -p 80:80 -p 889:888 -v ~/website_data:/www/wwwroot -v ~/mysql_data:/www/server/data -v ~/vhost:/www/server/panel/vhost yakpanel/yakpanel:lib`
|
||||
|
||||
Now you can access YakPanel at http://youripaddress:8886/ from your host system.
|
||||
|
||||
* Default username:`yakpanel`
|
||||
* Default password:`yakpanel123`
|
||||
|
||||
Port usage analysis
|
||||
* Control Panel : 8888
|
||||
* Phpmyadmin : 888
|
||||
|
||||
Dir usage analysis
|
||||
* Website data : /www/wwwroot
|
||||
* Mysql data : /www/server/data
|
||||
* Vhost file : /www/server/panel/vhost
|
||||
|
||||
**Note: after the deployment is complete, please immediately modify the user name and password in the panel settings and add the installation entry**
|
||||
|
||||
|
||||
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues to `1249648969@qq.com`
|
||||
472
Yak-Panel
Normal file
472
Yak-Panel
Normal file
@@ -0,0 +1,472 @@
|
||||
#!/www/server/panel/pyenv/bin/python
|
||||
#coding: utf-8
|
||||
# +-------------------------------------------------------------------
|
||||
# | YakPanel
|
||||
# +-------------------------------------------------------------------
|
||||
# | Copyright (c) 2015-2099 YakPanel(www.yakpanel.com) All rights reserved.
|
||||
# +-------------------------------------------------------------------
|
||||
# | Author: hwliang <hwl@yakpanel.com>
|
||||
# +-------------------------------------------------------------------
|
||||
from gevent import monkey
|
||||
|
||||
|
||||
monkey.patch_all()
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
import ssl
|
||||
import time
|
||||
import logging
|
||||
import psutil
|
||||
|
||||
|
||||
_PATH = '/www/server/panel'
|
||||
os.chdir(_PATH)
|
||||
|
||||
# upgrade_file = 'script/upgrade_flask.sh'
|
||||
# if os.path.exists(upgrade_file):
|
||||
# os.system("nohup bash {} &>/dev/null &".format(upgrade_file))
|
||||
#
|
||||
# upgrade_file = 'script/upgrade_gevent.sh'
|
||||
# if os.path.exists(upgrade_file):
|
||||
# os.system("nohup bash {} &>/dev/null &".format(upgrade_file))
|
||||
|
||||
upgrade_file = 'script/upgrade_telegram.sh'
|
||||
if os.path.exists(upgrade_file):
|
||||
os.system("nohup bash {} &>/dev/null &".format(upgrade_file))
|
||||
|
||||
if os.path.exists('class/flask'):
|
||||
os.system('rm -rf class/flask')
|
||||
|
||||
|
||||
if not 'class/' in sys.path:
|
||||
sys.path.insert(0,'class/')
|
||||
from YakPanel import app,sys,public
|
||||
is_debug = os.path.exists('data/debug.pl')
|
||||
|
||||
|
||||
# 检查加载器
|
||||
def check_plugin_loader():
|
||||
plugin_loader_file = 'class/PluginLoader.so'
|
||||
machine = 'x86_64'
|
||||
try:
|
||||
machine = os.uname().machine
|
||||
except:
|
||||
pass
|
||||
plugin_loader_src_file = "class/PluginLoader.{}.Python3.12.so".format(machine)
|
||||
if machine == 'x86_64':
|
||||
glibc_version = public.get_glibc_version()
|
||||
if glibc_version in ['2.14','2.13','2.12','2.11','2.10']:
|
||||
plugin_loader_src_file = "class/PluginLoader.{}.glibc214.Python3.12.so".format(machine)
|
||||
if os.path.exists(plugin_loader_src_file):
|
||||
os.system(r"\cp -f {} {}".format(plugin_loader_src_file, plugin_loader_file))
|
||||
|
||||
check_plugin_loader()
|
||||
|
||||
|
||||
if is_debug:
|
||||
import pyinotify,time,logging,re
|
||||
logging.basicConfig(level=logging.DEBUG,format="[%(asctime)s][%(levelname)s] - %(message)s")
|
||||
logger = logging.getLogger()
|
||||
|
||||
class PanelEventHandler(pyinotify.ProcessEvent):
|
||||
_exts = ['py','html','Yak-Panel','so']
|
||||
_explude_patts = [
|
||||
re.compile('{}/plugin/.+'.format(_PATH)),
|
||||
re.compile('{}/(tmp|temp)/.+'.format(_PATH)),
|
||||
re.compile('{}/pyenv/.+'.format(_PATH)),
|
||||
re.compile('{}/class/projectModel/.+'.format(_PATH)),
|
||||
re.compile('{}/class/databaseModel/.+'.format(_PATH)),
|
||||
re.compile('{}/panel/data/mail/in_bulk/content/.+'.format(_PATH))
|
||||
]
|
||||
_lsat_time = 0
|
||||
|
||||
|
||||
def is_ext(self,filename):
|
||||
fname = os.path.basename(filename)
|
||||
result = fname.split('.')[-1] in self._exts
|
||||
if not result: return False
|
||||
for e in self._explude_patts:
|
||||
if e.match(filename): return False
|
||||
return True
|
||||
|
||||
def panel_reload(self,filename,in_type):
|
||||
stime = time.time()
|
||||
if stime - self._lsat_time < 2:
|
||||
return
|
||||
self._lsat_time = stime
|
||||
logger.debug('File detected: {} -> {}'.format(filename,in_type))
|
||||
|
||||
fname = os.path.basename(filename)
|
||||
os.chmod(_PATH + "/Yak-Panel", 700)
|
||||
os.chmod(_PATH + "/Yak-Task", 700)
|
||||
if fname in ['task.py','Yak-Task']:
|
||||
logger.debug('Background task...')
|
||||
public.ExecShell("{} {}/Yak-Task".format(public.get_python_bin(),_PATH))
|
||||
logger.debug('Background task started!')
|
||||
else:
|
||||
logger.debug('Restarting panel...')
|
||||
public.ExecShell("bash {}/init.sh reload &>/dev/null &".format(_PATH))
|
||||
|
||||
def process_IN_CREATE(self, event):
|
||||
if not self.is_ext(event.pathname): return
|
||||
self.panel_reload(event.pathname,'[Create]')
|
||||
|
||||
def process_IN_DELETE(self,event):
|
||||
if not self.is_ext(event.pathname): return
|
||||
self.panel_reload(event.pathname,'[Delete]')
|
||||
|
||||
def process_IN_MODIFY(self,event):
|
||||
|
||||
if not self.is_ext(event.pathname): return
|
||||
self.panel_reload(event.pathname,'[Modify]')
|
||||
|
||||
def process_IN_MOVED_TO(self,event):
|
||||
if not self.is_ext(event.pathname): return
|
||||
self.panel_reload(event.pathname,'[Cover]')
|
||||
def debug_event():
|
||||
logger.debug('Launch the panel in debug mode')
|
||||
logger.debug('Listening port:0.0.0.0:{}'.format(public.readFile('data/port.pl')))
|
||||
|
||||
event = PanelEventHandler()
|
||||
watchManager = pyinotify.WatchManager()
|
||||
mode = pyinotify.IN_CREATE | pyinotify.IN_DELETE | pyinotify.IN_MODIFY | pyinotify.IN_MOVED_TO
|
||||
watchManager.add_watch(_PATH, mode, auto_add=True, rec=True)
|
||||
notifier = pyinotify.Notifier(watchManager, event)
|
||||
notifier.loop()
|
||||
|
||||
def run_task():
|
||||
public.ExecShell("chmod 700 {}/Yak-Task".format(_PATH))
|
||||
public.ExecShell("{}/Yak-Task".format(_PATH))
|
||||
|
||||
def daemon_task():
|
||||
cycle = 60
|
||||
task_pid_file = "{}/logs/task.pid".format(_PATH)
|
||||
while 1:
|
||||
time.sleep(cycle)
|
||||
|
||||
# 检查pid文件是否存在
|
||||
if not os.path.exists(task_pid_file):
|
||||
continue
|
||||
|
||||
# 读取pid文件
|
||||
task_pid = public.readFile(task_pid_file)
|
||||
if not task_pid:
|
||||
run_task()
|
||||
continue
|
||||
|
||||
# 检查进程是否存在
|
||||
comm_file = "/proc/{}/comm".format(task_pid)
|
||||
if not os.path.exists(comm_file):
|
||||
run_task()
|
||||
continue
|
||||
|
||||
# 是否为面板进程
|
||||
comm = public.readFile(comm_file)
|
||||
if comm.find('Yak-Task') == -1:
|
||||
run_task()
|
||||
continue
|
||||
|
||||
def get_process_count():
|
||||
'''
|
||||
@name 获取进程数量
|
||||
@return int
|
||||
'''
|
||||
|
||||
# 如果存在用户配置,则直接返回用户配置的进程数量
|
||||
process_count_file = "{}/data/process_count.pl".format(_PATH)
|
||||
if os.path.exists(process_count_file):
|
||||
str_count = public.readFile(process_count_file).strip()
|
||||
try:
|
||||
if str_count: return int(str_count)
|
||||
except: pass
|
||||
|
||||
# 否则根据内存和CPU核心数来决定启动进程数量
|
||||
memory = psutil.virtual_memory().total / 1024 / 1024
|
||||
cpu_count = psutil.cpu_count()
|
||||
if memory < 4000 or cpu_count < 4: return 1 # 内存小于4G或CPU核心小于4核,则只启动1个进程
|
||||
if memory < 8000 and cpu_count > 3: return 2 # 内存大于4G且小于8G,且CPU核心大于3核,则启动2个进程
|
||||
if memory > 14000 and cpu_count > 7: return 3 # 内存大于8G且14G,且CPU核心大于7核,则启动3个进程
|
||||
if memory > 30000 and cpu_count > 15: return 4 # 内存大于30G且CPU核心大于15核,则启动4个进程
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
def check_system_restarted():
|
||||
'''
|
||||
@name 检测系统是否重启
|
||||
1. 若与上次记录时间差异 > 3分钟 → 视为真实重启,标记 status=0(未读)
|
||||
2. 若差异 ≤ 3分钟 → 视为时间微调,更新 last_reboot_time,保持/设置 status=1(已读)
|
||||
3. 首次运行则初始化记录
|
||||
'''
|
||||
try:
|
||||
import json
|
||||
status_file = '{}/data/reboot_notification.json'.format(_PATH)
|
||||
current_boot_time = int(psutil.boot_time())
|
||||
|
||||
if os.path.exists(status_file):
|
||||
try:
|
||||
with open(status_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
last_recorded_time = data.get("last_reboot_time", 0)
|
||||
last_status = data.get("status", 1)
|
||||
|
||||
if last_recorded_time > 0:
|
||||
diff = abs(current_boot_time - last_recorded_time)
|
||||
if diff > 60 * 3:
|
||||
new_data = {
|
||||
"last_reboot_time": current_boot_time,
|
||||
"status": 0
|
||||
}
|
||||
with open(status_file, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
return True
|
||||
elif diff > 0: # 有变化才写入
|
||||
new_data = {
|
||||
"last_reboot_time": current_boot_time,
|
||||
"status": last_status if last_status == 0 else 1
|
||||
}
|
||||
try:
|
||||
with open(status_file, 'w') as f:
|
||||
json.dump(new_data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Failed to update boot time (drift): {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading/parsing status file: {e}")
|
||||
|
||||
else:
|
||||
# 首次运行,保存当前启动时间
|
||||
with open(status_file, 'w') as f:
|
||||
json.dump({
|
||||
"last_reboot_time": current_boot_time,
|
||||
"status": 1
|
||||
}, f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return False # 默认没有重启
|
||||
|
||||
if __name__ == '__main__':
|
||||
pid_file = "{}/logs/panel.pid".format(_PATH)
|
||||
if os.path.exists(pid_file):
|
||||
public.ExecShell("kill -9 {}".format(public.readFile(pid_file)))
|
||||
|
||||
# 重启面板前检查系统是否重启
|
||||
check_system_restarted()
|
||||
|
||||
pid = os.fork()
|
||||
if pid: sys.exit(0)
|
||||
|
||||
os.setsid()
|
||||
|
||||
_pid = os.fork()
|
||||
if _pid:
|
||||
public.writeFile(pid_file,str(_pid))
|
||||
sys.exit(0)
|
||||
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
|
||||
# 面板启动任务初始化
|
||||
os.system("nohup ./pyenv/bin/python3 class/jobs.py &>/dev/null &")
|
||||
|
||||
try:
|
||||
f = open('data/port.pl')
|
||||
PORT = int(f.read())
|
||||
f.close()
|
||||
if not PORT: PORT = 7800
|
||||
except:
|
||||
PORT = 7800
|
||||
HOST = '0.0.0.0'
|
||||
if os.path.exists('data/ipv6.pl'):
|
||||
HOST = "0:0:0:0:0:0:0:0"
|
||||
|
||||
keyfile = 'ssl/privateKey.pem'
|
||||
certfile = 'ssl/certificate.pem'
|
||||
is_ssl = False
|
||||
if os.path.exists('data/ssl.pl') and os.path.exists(keyfile) and os.path.exists(certfile):
|
||||
is_ssl = True
|
||||
|
||||
if not is_ssl or is_debug:
|
||||
try:
|
||||
err_f = open('logs/error.log','a+')
|
||||
os.dup2(err_f.fileno(),sys.stderr.fileno())
|
||||
err_f.close()
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
|
||||
import threading
|
||||
task_thread = threading.Thread(target=daemon_task, daemon=True)
|
||||
task_thread.start()
|
||||
|
||||
if is_ssl:
|
||||
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
ssl_context.load_cert_chain(certfile=certfile,keyfile=keyfile)
|
||||
if hasattr(ssl_context, "minimum_version"):
|
||||
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
else:
|
||||
ssl_context.options = (ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1)
|
||||
|
||||
ssl_context.set_ciphers("ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE")
|
||||
is_ssl_verify = os.path.exists('/www/server/panel/data/ssl_verify_data.pl')
|
||||
if is_ssl_verify:
|
||||
crlfile = '/www/server/panel/ssl/crl.pem'
|
||||
rootcafile = '/www/server/panel/ssl/ca.pem'
|
||||
#注销列表
|
||||
# ssl_context.load_verify_locations(crlfile)
|
||||
# ssl_context.verify_flags |= ssl.VERIFY_CRL_CHECK_CHAIN
|
||||
#加载证书
|
||||
ssl_context.load_verify_locations(rootcafile)
|
||||
ssl_context.verify_mode = ssl.CERT_REQUIRED
|
||||
ssl_context.set_default_verify_paths()
|
||||
|
||||
# 设置日志格式
|
||||
_level = logging.WARNING
|
||||
if is_debug: _level = logging.NOTSET
|
||||
logging.basicConfig(level=_level,format="[%(asctime)s][%(levelname)s] - %(message)s")
|
||||
logger = logging.getLogger()
|
||||
app.logger = logger
|
||||
|
||||
from gevent.pywsgi import WSGIServer
|
||||
import webserver
|
||||
|
||||
class BtWSGIServer(WSGIServer):
|
||||
def wrap_socket_and_handle(self, client_socket, address):
|
||||
try:
|
||||
return super(BtWSGIServer, self).wrap_socket_and_handle(client_socket, address)
|
||||
except OSError as e:
|
||||
pass
|
||||
# public.print_exc_stack(e)
|
||||
|
||||
def do_read(self):
|
||||
try:
|
||||
return super(BtWSGIServer, self).do_read()
|
||||
except OSError as e:
|
||||
pass
|
||||
# public.print_exc_stack(e)
|
||||
|
||||
webserver_obj = webserver.webserver()
|
||||
is_webserver = webserver_obj.run_webserver()
|
||||
# is_webserver = False
|
||||
|
||||
if is_webserver:
|
||||
from gevent import socket
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
unix_socket = '/tmp/panel.sock'
|
||||
if os.path.exists(unix_socket):
|
||||
os.remove(unix_socket)
|
||||
listener.bind(unix_socket)
|
||||
listener.listen(500)
|
||||
os.chmod(unix_socket, 0o777)
|
||||
try:
|
||||
import flask_sock
|
||||
http_server = BtWSGIServer(listener, app,log=app.logger)
|
||||
except:
|
||||
from geventwebsocket.handler import WebSocketHandler
|
||||
http_server = BtWSGIServer(listener, app,handler_class=WebSocketHandler,log=app.logger)
|
||||
else:
|
||||
if is_ssl:
|
||||
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
ssl_context.load_cert_chain(certfile=certfile, keyfile=keyfile)
|
||||
if hasattr(ssl_context, "minimum_version"):
|
||||
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
else:
|
||||
ssl_context.options = (ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1)
|
||||
|
||||
ssl_context.set_ciphers("ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE")
|
||||
is_ssl_verify = os.path.exists('/www/server/panel/data/ssl_verify_data.pl')
|
||||
if is_ssl_verify:
|
||||
crlfile = '/www/server/panel/ssl/crl.pem'
|
||||
rootcafile = '/www/server/panel/ssl/ca.pem'
|
||||
#注销列表
|
||||
# ssl_context.load_verify_locations(crlfile)
|
||||
# ssl_context.verify_flags |= ssl.VERIFY_CRL_CHECK_CHAIN
|
||||
#加载证书
|
||||
ssl_context.load_verify_locations(rootcafile)
|
||||
ssl_context.verify_mode = ssl.CERT_REQUIRED
|
||||
ssl_context.set_default_verify_paths()
|
||||
|
||||
try:
|
||||
import flask_sock
|
||||
if is_ssl:
|
||||
http_server = BtWSGIServer((HOST, PORT), app,ssl_context = ssl_context,log=app.logger)
|
||||
else:
|
||||
http_server = BtWSGIServer((HOST, PORT), app,log=app.logger)
|
||||
except:
|
||||
from geventwebsocket.handler import WebSocketHandler
|
||||
if is_ssl:
|
||||
http_server = BtWSGIServer((HOST, PORT), app,ssl_context = ssl_context,handler_class=WebSocketHandler,log=app.logger)
|
||||
else:
|
||||
http_server = BtWSGIServer((HOST, PORT), app,handler_class=WebSocketHandler,log=app.logger)
|
||||
|
||||
|
||||
if is_debug:
|
||||
try:
|
||||
dev = threading.Thread(target=debug_event)
|
||||
dev.start()
|
||||
except:
|
||||
pass
|
||||
|
||||
is_process = os.path.exists('data/is_process.pl')
|
||||
if not is_process:
|
||||
try:
|
||||
http_server.serve_forever()
|
||||
except:
|
||||
from traceback import format_exc
|
||||
public.print_log(format_exc())
|
||||
app.run(host=HOST, port=PORT, threaded=True)
|
||||
else:
|
||||
http_server.start()
|
||||
from multiprocessing import Process
|
||||
|
||||
|
||||
def serve_forever():
|
||||
http_server.start_accepting()
|
||||
http_server._stop_event.wait()
|
||||
|
||||
|
||||
# 获取最大进程数量,最小为2个
|
||||
process_count = get_process_count()
|
||||
if process_count < 2: process_count = 2
|
||||
|
||||
# 启动主进程
|
||||
main_p = Process(target=serve_forever)
|
||||
main_p.daemon = True
|
||||
main_p.start()
|
||||
main_psutil = psutil.Process(main_p.pid)
|
||||
|
||||
# 动态按需调整子进程数量
|
||||
process_dict = {}
|
||||
while 1:
|
||||
t = time.time()
|
||||
# 当主进程CPU占用率超过90%时,尝试启动新的子进程协同处理
|
||||
cpu_percent = main_psutil.cpu_percent(interval=1)
|
||||
if cpu_percent > 90:
|
||||
is_alive = 0
|
||||
process_num = 0
|
||||
|
||||
# 检查是否存在空闲的子进程
|
||||
for i in process_dict.keys():
|
||||
process_num += 1
|
||||
if process_dict[i][2].cpu_percent(interval=1) > 0:
|
||||
is_alive += 1
|
||||
|
||||
# 如果没有空闲的子进程,且当前子进程数量小于最大进程数量,则启动新的子进程
|
||||
if process_num == is_alive and process_num < process_count:
|
||||
p = Process(target=serve_forever)
|
||||
p.daemon = True
|
||||
p.start()
|
||||
process_dict[p.pid] = [p, t, psutil.Process(p.pid)]
|
||||
|
||||
# 结束创建时间超过60秒,且连续空闲5秒钟以上的子进程
|
||||
keys = list(process_dict.keys())
|
||||
for i in keys:
|
||||
if t - process_dict[i][1] < 60: continue
|
||||
if process_dict[i][2].cpu_percent(interval=5) > 0: continue
|
||||
process_dict[i][0].kill()
|
||||
process_dict.pop(i)
|
||||
|
||||
time.sleep(1)
|
||||
26
Yak-Task
Normal file
26
Yak-Task
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/www/server/panel/pyenv/bin/python
|
||||
# coding: utf-8
|
||||
# +-------------------------------------------------------------------
|
||||
# | YakPanel
|
||||
# +-------------------------------------------------------------------
|
||||
# | Copyright (c) 2015-2099 YakPanel (https://www.yakpanel.com) All rights reserved.
|
||||
# +-------------------------------------------------------------------
|
||||
# | Author: hwliang <hwl@yakpanel.com>
|
||||
# +-------------------------------------------------------------------
|
||||
import argparse
|
||||
import os
|
||||
|
||||
os.chdir('/www/server/panel')
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description='YakPanel task service')
|
||||
parser.add_argument(
|
||||
'--max-workers',
|
||||
type=int,
|
||||
default=None,
|
||||
help='Maximum number of worker threads'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
from YakTask import task
|
||||
task.main(max_workers=args.max_workers)
|
||||
176
YakPanel-server/README.md
Normal file
176
YakPanel-server/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# YakPanel
|
||||
|
||||
A web hosting control panel for Linux servers (Ubuntu 22+/Debian, Rocky/Alma 9, EL with `dnf`/`yum`). Built with FastAPI, React, and SQLAlchemy. Descended from YakPanel-style panels, rebuilt with a modern stack.
|
||||
|
||||
**YakPanel (yakpanel.com)** treats this repo as the baseline implementation: stack choice, security/privilege model, and distribution strategy are documented in [`../../YakPanel-product/`](../../YakPanel-product/).
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard** - System stats, site/FTP/DB counts
|
||||
- **Website Management** - Create sites, Nginx vhost, domains, Git deploy (clone/pull)
|
||||
- **FTP** - FTP account management
|
||||
- **Databases** - MySQL, PostgreSQL, Redis, MongoDB (create, backup, restore)
|
||||
- **Files** - File manager (list, read, edit, upload, download, mkdir, rename, delete)
|
||||
- **Cron** - Scheduled tasks
|
||||
- **Firewall** - Port rules
|
||||
- **SSL** - Let's Encrypt certificates via Certbot
|
||||
- **Docker** - Container list, start, stop, restart
|
||||
- **Plugins** - Built-in extensions + third-party plugins (add from JSON manifest URL)
|
||||
- **Backup Plans** - Scheduled site and database backups
|
||||
- **Users** - Multi-user management (admin only)
|
||||
|
||||
## Linux install options (one-click)
|
||||
|
||||
All native installs require **root**. Use `sudo -E ...` when you set environment variables so they are preserved.
|
||||
|
||||
| Method | When to use |
|
||||
| --- | --- |
|
||||
| **curl** | Default; Debian/Ubuntu/RHEL-family with `curl` |
|
||||
| **wget** | Host has `wget` but not `curl` |
|
||||
| **Bootstrap `install-curl.sh`** | Same as curl but `YAKPANEL_INSTALLER_BASE` points at your mirror |
|
||||
| **Local / air-gap** | Tree already on disk: `YAKPANEL_SOURCE_DIR` or `scripts/install.sh` |
|
||||
| **Docker Compose** | Quick trial / CI; different ports than native (see below) |
|
||||
| **Web + SSH** | Optional: browser UI at **`/install`** runs the same `install.sh` over **SSH** (off by default; see below) |
|
||||
|
||||
### Web-based remote installer (SSH)
|
||||
|
||||
**Disabled by default.** Set `ENABLE_REMOTE_INSTALLER=true` in the API environment and restart the backend. Then open the SPA at **`/install`** (e.g. `http://your-panel:8888/install` behind Nginx, or Vite dev with proxy).
|
||||
|
||||
- **Security:** The browser sends SSH credentials to your **YakPanel API**; they are **not** stored in the database. Prefer **SSH keys**. **Non-root** users must have **passwordless sudo** (`sudo -n`) because the session is non-interactive. The host running the API must be allowed to reach the **target:SSH port** (and the target must allow **outbound HTTPS** to run `curl` + clone + NodeSource as in `install.sh`).
|
||||
- **Tuning (env):** `REMOTE_INSTALL_DEFAULT_URL` (HTTPS `install.sh` only), `REMOTE_INSTALL_RATE_LIMIT_PER_IP`, `REMOTE_INSTALL_RATE_WINDOW_MINUTES`, `REMOTE_INSTALL_ALLOWED_TARGET_CIDRS` (comma-separated CIDRs; empty = no restriction), `CORS_EXTRA_ORIGINS` for extra browser origins in production.
|
||||
- **API:** `GET /api/v1/public-install/config`, `POST /api/v1/public-install/jobs`, WebSocket `/api/v1/public-install/ws/{job_id}` (JSON messages: `line`, `done`).
|
||||
|
||||
### Supported distros (native installer)
|
||||
|
||||
- **Debian/Ubuntu**: `apt-get` (Nginx `sites-available` layout).
|
||||
- **RHEL-family** (Rocky, Alma, CentOS Stream, etc.): `dnf` or `yum` (Nginx `conf.d` layout, `firewalld` port if active).
|
||||
|
||||
### Environment variables (native `install.sh`)
|
||||
|
||||
| Variable | Meaning | Default |
|
||||
| --- | --- | --- |
|
||||
| `REPO_URL` | Git URL to clone | `https://github.com/YakPanel/YakPanel.git` (optional: `https://source.yakpanel.com/yakpanel.git` when your mirror is live) |
|
||||
| `YAKPANEL_BRANCH` | Branch/tag for shallow clone | default branch |
|
||||
| `GIT_REF` | Alias for `YAKPANEL_BRANCH` | — |
|
||||
| `INSTALL_PATH` | Install directory | `/www/server/YakPanel-server` |
|
||||
| `PANEL_PORT` | Public HTTP port (Nginx) | `8888` |
|
||||
| `BACKEND_PORT` | Uvicorn (localhost) | `8889` |
|
||||
| `YAKPANEL_SOURCE_DIR` | Skip git; path with `backend/` and `frontend/` | unset |
|
||||
|
||||
CLI flags: `--repo-url`, `--install-path`, `--branch` / `--ref`, `--source-dir`, `--panel-port`, `--backend-port`, `--help`.
|
||||
|
||||
### One-liners (official CDN layout)
|
||||
|
||||
Paths assume you publish `install.sh` next to this repo under `…/YakPanel-server/` on your web server.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://www.yakpanel.com/YakPanel-server/install.sh | sudo bash
|
||||
```
|
||||
|
||||
```bash
|
||||
wget -qO- https://www.yakpanel.com/YakPanel-server/install.sh | sudo bash
|
||||
```
|
||||
|
||||
Mirror / GitHub raw (set your base; no trailing `install.sh`):
|
||||
|
||||
```bash
|
||||
export YAKPANEL_INSTALLER_BASE=https://www.yakpanel.com/YakPanel-server
|
||||
curl -fsSL "${YAKPANEL_INSTALLER_BASE}/install-curl.sh" | sudo -E bash
|
||||
```
|
||||
|
||||
Custom git mirror and branch:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://www.yakpanel.com/YakPanel-server/install.sh | sudo -E env REPO_URL=https://git.example.com/yakpanel.git YAKPANEL_BRANCH=main bash
|
||||
```
|
||||
|
||||
### Local tree / air-gapped
|
||||
|
||||
From the `YakPanel-server` directory (must contain `backend/` and `frontend/`):
|
||||
|
||||
```bash
|
||||
sudo YAKPANEL_SOURCE_DIR="$(pwd)" bash install.sh
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/install.sh
|
||||
```
|
||||
|
||||
### Docker (evaluation)
|
||||
|
||||
Uses `docker-compose.yml` in this directory — **not** the same layout as native (no host Nginx unit from `install.sh`).
|
||||
|
||||
```bash
|
||||
git clone --depth 1 https://github.com/YakPanel/YakPanel.git
|
||||
# Then cd to this folder (in the full monorepo it is under YakPanel-master/YakPanel-server).
|
||||
cd YakPanel-master/YakPanel-server
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- **Backend**: `8888` (API on container)
|
||||
- **Frontend dev server image**: `5173`
|
||||
- **Redis**: `6379`
|
||||
|
||||
For a single compose command without `cd`, set `-f` to your checkout’s `docker-compose.yml`.
|
||||
|
||||
**Post-install (all methods):** change the default `admin` password, restrict firewall to SSH + panel port, add TLS (e.g. Let’s Encrypt) for production.
|
||||
|
||||
**SELinux (RHEL):** if Nginx returns 403 on static files, fix file contexts or test with permissive mode; see your distro SELinux docs.
|
||||
|
||||
## Quick Start (development)
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd YakPanel-server/backend
|
||||
python -m venv venv
|
||||
# Windows: venv\Scripts\activate
|
||||
# Linux: source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python scripts/seed_admin.py # Create admin user (admin/admin)
|
||||
python run.py
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd YakPanel-server/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- Backend: http://localhost:8888
|
||||
- Frontend: http://localhost:5173
|
||||
- Login: admin / admin
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
YakPanel-server/
|
||||
├── install.sh # Canonical native installer
|
||||
├── install-curl.sh # Optional: fetch install.sh from YAKPANEL_INSTALLER_BASE
|
||||
├── backend/ # FastAPI application
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # Route handlers
|
||||
│ │ ├── core/ # Config, security, utils
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ └── tasks/ # Celery tasks
|
||||
│ └── scripts/ # Seed, etc.
|
||||
├── frontend/ # React + Vite SPA
|
||||
├── webserver/ # Nginx vhost templates
|
||||
├── scripts/ # Delegates to install.sh (local source)
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Backend: FastAPI, SQLAlchemy 2.0, Celery, Redis
|
||||
- Frontend: React 18, Vite, TypeScript, Tailwind CSS
|
||||
- Auth: JWT, bcrypt
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
14
YakPanel-server/backend/.env.example
Normal file
14
YakPanel-server/backend/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# YakPanel - Environment
|
||||
SECRET_KEY=change-this-in-production
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/default.db
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
DEBUG=false
|
||||
PANEL_PORT=8888
|
||||
|
||||
# Optional: remote SSH installer (use with caution)
|
||||
# ENABLE_REMOTE_INSTALLER=false
|
||||
# REMOTE_INSTALL_DEFAULT_URL=https://www.yakpanel.com/YakPanel-server/install.sh
|
||||
# REMOTE_INSTALL_RATE_LIMIT_PER_IP=10
|
||||
# REMOTE_INSTALL_RATE_WINDOW_MINUTES=60
|
||||
# REMOTE_INSTALL_ALLOWED_TARGET_CIDRS=
|
||||
# CORS_EXTRA_ORIGINS=https://your-panel.example.com
|
||||
10
YakPanel-server/backend/Dockerfile
Normal file
10
YakPanel-server/backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8888"]
|
||||
1
YakPanel-server/backend/app/__init__.py
Normal file
1
YakPanel-server/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# YakPanel - Backend Application
|
||||
BIN
YakPanel-server/backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
YakPanel-server/backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
1
YakPanel-server/backend/app/api/__init__.py
Normal file
1
YakPanel-server/backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# YakPanel - API routes
|
||||
Binary file not shown.
Binary file not shown.
90
YakPanel-server/backend/app/api/auth.py
Normal file
90
YakPanel-server/backend/app/api/auth.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""YakPanel - Auth API"""
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token, decode_token
|
||||
from app.core.config import get_settings
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Get current authenticated user from JWT"""
|
||||
credentials_exception = HTTPException(status_code=401, detail="Invalid credentials")
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise credentials_exception
|
||||
sub = payload.get("sub")
|
||||
user_id = int(sub) if isinstance(sub, str) else sub
|
||||
if not user_id:
|
||||
raise credentials_exception
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise credentials_exception
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="User inactive")
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Login and return JWT token"""
|
||||
from sqlalchemy import select
|
||||
result = await db.execute(select(User).where(User.username == form_data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(form_data.password, user.password):
|
||||
raise HTTPException(status_code=401, detail="Incorrect username or password")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="User inactive")
|
||||
access_token = create_access_token(
|
||||
data={"sub": str(user.id)},
|
||||
expires_delta=timedelta(minutes=get_settings().access_token_expire_minutes),
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer", "user": {"id": user.id, "username": user.username}}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout (client should discard token)"""
|
||||
return {"message": "Logged out"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user info"""
|
||||
return {"id": current_user.id, "username": current_user.username, "email": current_user.email, "is_superuser": current_user.is_superuser}
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change password"""
|
||||
if not verify_password(body.old_password, current_user.password):
|
||||
raise HTTPException(status_code=400, detail="Incorrect current password")
|
||||
result = await db.execute(select(User).where(User.id == current_user.id))
|
||||
user = result.scalar_one()
|
||||
user.password = get_password_hash(body.new_password)
|
||||
await db.commit()
|
||||
return {"message": "Password changed"}
|
||||
204
YakPanel-server/backend/app/api/backup.py
Normal file
204
YakPanel-server/backend/app/api/backup.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""YakPanel - Backup plans API"""
|
||||
import os
|
||||
import tarfile
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from croniter import croniter
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.notification import send_email
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.site import Site
|
||||
from app.models.database import Database
|
||||
from app.models.backup_plan import BackupPlan
|
||||
from app.services.database_service import backup_mysql_database, backup_postgresql_database, backup_mongodb_database
|
||||
|
||||
router = APIRouter(prefix="/backup", tags=["backup"])
|
||||
|
||||
|
||||
class CreateBackupPlanRequest(BaseModel):
|
||||
name: str
|
||||
plan_type: str # site | database
|
||||
target_id: int
|
||||
schedule: str # cron, e.g. "0 2 * * *"
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
async def backup_plans_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all backup plans"""
|
||||
result = await db.execute(select(BackupPlan).order_by(BackupPlan.id))
|
||||
rows = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"plan_type": r.plan_type,
|
||||
"target_id": r.target_id,
|
||||
"schedule": r.schedule,
|
||||
"enabled": r.enabled,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/plans")
|
||||
async def backup_plan_create(
|
||||
body: CreateBackupPlanRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a backup plan"""
|
||||
if body.plan_type not in ("site", "database"):
|
||||
raise HTTPException(status_code=400, detail="plan_type must be site or database")
|
||||
if not body.schedule or len(body.schedule) < 9:
|
||||
raise HTTPException(status_code=400, detail="Invalid cron schedule")
|
||||
try:
|
||||
croniter(body.schedule)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid cron expression")
|
||||
if body.plan_type == "site":
|
||||
r = await db.execute(select(Site).where(Site.id == body.target_id))
|
||||
if not r.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
else:
|
||||
r = await db.execute(select(Database).where(Database.id == body.target_id))
|
||||
if not r.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
plan = BackupPlan(
|
||||
name=body.name,
|
||||
plan_type=body.plan_type,
|
||||
target_id=body.target_id,
|
||||
schedule=body.schedule,
|
||||
enabled=body.enabled,
|
||||
)
|
||||
db.add(plan)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Backup plan created", "id": plan.id}
|
||||
|
||||
|
||||
@router.put("/plans/{plan_id}")
|
||||
async def backup_plan_update(
|
||||
plan_id: int,
|
||||
body: CreateBackupPlanRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a backup plan"""
|
||||
result = await db.execute(select(BackupPlan).where(BackupPlan.id == plan_id))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Backup plan not found")
|
||||
if body.schedule:
|
||||
try:
|
||||
croniter(body.schedule)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid cron expression")
|
||||
plan.name = body.name
|
||||
plan.plan_type = body.plan_type
|
||||
plan.target_id = body.target_id
|
||||
plan.schedule = body.schedule
|
||||
plan.enabled = body.enabled
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Updated"}
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}")
|
||||
async def backup_plan_delete(
|
||||
plan_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a backup plan"""
|
||||
result = await db.execute(select(BackupPlan).where(BackupPlan.id == plan_id))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Backup plan not found")
|
||||
await db.delete(plan)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Deleted"}
|
||||
|
||||
|
||||
def _run_site_backup(site: Site) -> tuple[bool, str, str | None]:
|
||||
"""Run site backup (sync, for use in run_scheduled). Returns (ok, msg, filename)."""
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = cfg["backup_path"]
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{site.name}_{ts}.tar.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
try:
|
||||
with tarfile.open(dest, "w:gz") as tf:
|
||||
tf.add(site.path, arcname=os.path.basename(site.path))
|
||||
return True, "Backup created", filename
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def _run_database_backup(dbo: Database) -> tuple[bool, str, str | None]:
|
||||
"""Run database backup (sync). Returns (ok, msg, filename)."""
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = os.path.join(cfg["backup_path"], "database")
|
||||
if dbo.db_type == "MySQL":
|
||||
return backup_mysql_database(dbo.name, backup_dir)
|
||||
if dbo.db_type == "PostgreSQL":
|
||||
return backup_postgresql_database(dbo.name, backup_dir)
|
||||
if dbo.db_type == "MongoDB":
|
||||
return backup_mongodb_database(dbo.name, backup_dir)
|
||||
return False, "Unsupported database type", None
|
||||
|
||||
|
||||
@router.post("/run-scheduled")
|
||||
async def backup_run_scheduled(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run all due backup plans. Call this from cron (e.g. every hour) or manually."""
|
||||
from datetime import datetime as dt
|
||||
now = dt.utcnow()
|
||||
result = await db.execute(select(BackupPlan).where(BackupPlan.enabled == True))
|
||||
plans = result.scalars().all()
|
||||
results = []
|
||||
for plan in plans:
|
||||
try:
|
||||
prev_run = croniter(plan.schedule, now).get_prev(dt)
|
||||
# Run if we're within 15 minutes after the scheduled time
|
||||
secs_since = (now - prev_run).total_seconds()
|
||||
if secs_since > 900 or secs_since < 0: # Not within 15 min window
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
if plan.plan_type == "site":
|
||||
r = await db.execute(select(Site).where(Site.id == plan.target_id))
|
||||
site = r.scalar_one_or_none()
|
||||
if not site or not os.path.isdir(site.path):
|
||||
results.append({"plan": plan.name, "status": "skipped", "msg": "Site not found or path invalid"})
|
||||
continue
|
||||
ok, msg, filename = _run_site_backup(site)
|
||||
if ok:
|
||||
send_email(
|
||||
subject=f"YakPanel - Scheduled backup: {plan.name}",
|
||||
body=f"Site backup completed: {filename}\nSite: {site.name}",
|
||||
)
|
||||
else:
|
||||
r = await db.execute(select(Database).where(Database.id == plan.target_id))
|
||||
dbo = r.scalar_one_or_none()
|
||||
if not dbo:
|
||||
results.append({"plan": plan.name, "status": "skipped", "msg": "Database not found"})
|
||||
continue
|
||||
ok, msg, filename = _run_database_backup(dbo)
|
||||
if ok:
|
||||
send_email(
|
||||
subject=f"YakPanel - Scheduled backup: {plan.name}",
|
||||
body=f"Database backup completed: {filename}\nDatabase: {dbo.name}",
|
||||
)
|
||||
results.append({"plan": plan.name, "status": "ok" if ok else "failed", "msg": msg})
|
||||
return {"status": True, "results": results}
|
||||
83
YakPanel-server/backend/app/api/config.py
Normal file
83
YakPanel-server/backend/app/api/config.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""YakPanel - Config/Settings API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_settings, get_runtime_config, set_runtime_config_overrides
|
||||
from app.core.notification import send_email
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.config import Config
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
@router.get("/panel")
|
||||
async def get_panel_config(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get panel configuration (DB overrides applied)"""
|
||||
s = get_settings()
|
||||
cfg = get_runtime_config()
|
||||
return {
|
||||
"panel_port": cfg["panel_port"],
|
||||
"www_root": cfg["www_root"],
|
||||
"setup_path": cfg["setup_path"],
|
||||
"webserver_type": cfg["webserver_type"],
|
||||
"mysql_root_set": bool(cfg.get("mysql_root")),
|
||||
"app_name": s.app_name,
|
||||
"app_version": s.app_version,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def get_config_keys(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all config key-value pairs from DB"""
|
||||
result = await db.execute(select(Config).order_by(Config.key))
|
||||
rows = result.scalars().all()
|
||||
return {r.key: r.value for r in rows}
|
||||
|
||||
|
||||
@router.post("/set")
|
||||
async def set_config(
|
||||
body: ConfigUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set config value (stored in DB, runtime updated)"""
|
||||
result = await db.execute(select(Config).where(Config.key == body.key))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = body.value
|
||||
else:
|
||||
db.add(Config(key=body.key, value=body.value))
|
||||
await db.commit()
|
||||
# Reload runtime config so changes take effect without restart
|
||||
r2 = await db.execute(select(Config))
|
||||
overrides = {r.key: r.value for r in r2.scalars().all() if r.value is not None}
|
||||
set_runtime_config_overrides(overrides)
|
||||
return {"status": True, "msg": "Saved"}
|
||||
|
||||
|
||||
@router.post("/test-email")
|
||||
async def test_email(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Send a test email to verify SMTP configuration"""
|
||||
ok, msg = send_email(
|
||||
subject="YakPanel - Test Email",
|
||||
body="This is a test email from YakPanel. If you received this, your email configuration is working.",
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
return {"status": True, "msg": "Test email sent"}
|
||||
113
YakPanel-server/backend/app/api/crontab.py
Normal file
113
YakPanel-server/backend/app/api/crontab.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""YakPanel - Crontab API"""
|
||||
import tempfile
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.crontab import Crontab
|
||||
|
||||
router = APIRouter(prefix="/crontab", tags=["crontab"])
|
||||
|
||||
|
||||
class CreateCrontabRequest(BaseModel):
|
||||
name: str = ""
|
||||
type: str = "shell"
|
||||
schedule: str
|
||||
execstr: str
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def crontab_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List cron jobs"""
|
||||
result = await db.execute(select(Crontab).order_by(Crontab.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "name": r.name, "type": r.type, "schedule": r.schedule, "execstr": r.execstr} for r in rows]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def crontab_create(
|
||||
body: CreateCrontabRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create cron job"""
|
||||
cron = Crontab(name=body.name, type=body.type, schedule=body.schedule, execstr=body.execstr)
|
||||
db.add(cron)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Cron job created", "id": cron.id}
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def crontab_apply(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Sync panel cron jobs to system crontab (root)"""
|
||||
result = await db.execute(select(Crontab).order_by(Crontab.id))
|
||||
rows = result.scalars().all()
|
||||
lines = [
|
||||
"# YakPanel managed crontab - do not edit manually",
|
||||
"",
|
||||
]
|
||||
for r in rows:
|
||||
if r.name:
|
||||
lines.append(f"# {r.name}")
|
||||
lines.append(f"{r.schedule} {r.execstr}")
|
||||
lines.append("")
|
||||
content = "\n".join(lines).strip() + "\n"
|
||||
fd, path = tempfile.mkstemp(suffix=".crontab", prefix="cit_")
|
||||
try:
|
||||
os.write(fd, content.encode("utf-8"))
|
||||
os.close(fd)
|
||||
out, err = exec_shell_sync(f"crontab {path}", timeout=10)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
finally:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
return {"status": True, "msg": "Crontab applied", "count": len(rows)}
|
||||
|
||||
|
||||
@router.put("/{cron_id}")
|
||||
async def crontab_update(
|
||||
cron_id: int,
|
||||
body: CreateCrontabRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update cron job"""
|
||||
result = await db.execute(select(Crontab).where(Crontab.id == cron_id))
|
||||
cron = result.scalar_one_or_none()
|
||||
if not cron:
|
||||
raise HTTPException(status_code=404, detail="Cron job not found")
|
||||
cron.name = body.name
|
||||
cron.type = body.type
|
||||
cron.schedule = body.schedule
|
||||
cron.execstr = body.execstr
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Cron job updated"}
|
||||
|
||||
|
||||
@router.delete("/{cron_id}")
|
||||
async def crontab_delete(
|
||||
cron_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete cron job"""
|
||||
result = await db.execute(select(Crontab).where(Crontab.id == cron_id))
|
||||
cron = result.scalar_one_or_none()
|
||||
if not cron:
|
||||
raise HTTPException(status_code=404, detail="Cron job not found")
|
||||
await db.delete(cron)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Cron job deleted"}
|
||||
49
YakPanel-server/backend/app/api/dashboard.py
Normal file
49
YakPanel-server/backend/app/api/dashboard.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""YakPanel - Dashboard API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get dashboard statistics"""
|
||||
import psutil
|
||||
|
||||
from app.services.site_service import get_site_count
|
||||
from app.models.ftp import Ftp
|
||||
from app.models.database import Database
|
||||
from sqlalchemy import select, func
|
||||
site_count = await get_site_count(db)
|
||||
ftp_result = await db.execute(select(func.count()).select_from(Ftp))
|
||||
ftp_count = ftp_result.scalar() or 0
|
||||
db_result = await db.execute(select(func.count()).select_from(Database))
|
||||
database_count = db_result.scalar() or 0
|
||||
|
||||
# System stats
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage("/")
|
||||
|
||||
return {
|
||||
"site_count": site_count,
|
||||
"ftp_count": ftp_count,
|
||||
"database_count": database_count,
|
||||
"system": {
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory_percent": memory.percent,
|
||||
"memory_used_mb": round(memory.used / 1024 / 1024, 1),
|
||||
"memory_total_mb": round(memory.total / 1024 / 1024, 1),
|
||||
"disk_percent": disk.percent,
|
||||
"disk_used_gb": round(disk.used / 1024 / 1024 / 1024, 2),
|
||||
"disk_total_gb": round(disk.total / 1024 / 1024 / 1024, 2),
|
||||
},
|
||||
}
|
||||
274
YakPanel-server/backend/app/api/database.py
Normal file
274
YakPanel-server/backend/app/api/database.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""YakPanel - Database API"""
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.notification import send_email
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.database import Database
|
||||
from app.services.database_service import (
|
||||
create_mysql_database,
|
||||
drop_mysql_database,
|
||||
backup_mysql_database,
|
||||
restore_mysql_database,
|
||||
change_mysql_password,
|
||||
create_postgresql_database,
|
||||
drop_postgresql_database,
|
||||
backup_postgresql_database,
|
||||
restore_postgresql_database,
|
||||
change_postgresql_password,
|
||||
create_mongodb_database,
|
||||
drop_mongodb_database,
|
||||
backup_mongodb_database,
|
||||
restore_mongodb_database,
|
||||
change_mongodb_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/database", tags=["database"])
|
||||
|
||||
|
||||
class CreateDatabaseRequest(BaseModel):
|
||||
name: str
|
||||
username: str
|
||||
password: str
|
||||
db_type: str = "MySQL"
|
||||
ps: str = ""
|
||||
|
||||
|
||||
class UpdateDbPasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def database_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List databases"""
|
||||
result = await db.execute(select(Database).order_by(Database.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "name": r.name, "username": r.username, "db_type": r.db_type, "ps": r.ps} for r in rows]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def database_create(
|
||||
body: CreateDatabaseRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create database (panel DB + actual MySQL/PostgreSQL when supported)"""
|
||||
result = await db.execute(select(Database).where(Database.name == body.name))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Database already exists")
|
||||
if body.db_type == "MySQL":
|
||||
ok, msg = create_mysql_database(body.name, body.username, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
elif body.db_type == "PostgreSQL":
|
||||
ok, msg = create_postgresql_database(body.name, body.username, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
elif body.db_type == "MongoDB":
|
||||
ok, msg = create_mongodb_database(body.name, body.username, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
dbo = Database(
|
||||
name=body.name,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
db_type=body.db_type,
|
||||
ps=body.ps,
|
||||
)
|
||||
db.add(dbo)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Database created", "id": dbo.id}
|
||||
|
||||
|
||||
@router.put("/{db_id}/password")
|
||||
async def database_update_password(
|
||||
db_id: int,
|
||||
body: UpdateDbPasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change database user password (MySQL, PostgreSQL, or MongoDB)"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
if dbo.db_type not in ("MySQL", "PostgreSQL", "MongoDB"):
|
||||
raise HTTPException(status_code=400, detail="Only MySQL, PostgreSQL, and MongoDB supported")
|
||||
if not body.password or len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
if dbo.db_type == "MySQL":
|
||||
ok, msg = change_mysql_password(dbo.username, body.password)
|
||||
elif dbo.db_type == "PostgreSQL":
|
||||
ok, msg = change_postgresql_password(dbo.username, body.password)
|
||||
else:
|
||||
ok, msg = change_mongodb_password(dbo.username, dbo.name, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
dbo.password = body.password
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Password updated"}
|
||||
|
||||
|
||||
@router.delete("/{db_id}")
|
||||
async def database_delete(
|
||||
db_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete database (panel record + actual MySQL/PostgreSQL when supported)"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
if dbo.db_type == "MySQL":
|
||||
ok, msg = drop_mysql_database(dbo.name, dbo.username)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
elif dbo.db_type == "PostgreSQL":
|
||||
ok, msg = drop_postgresql_database(dbo.name, dbo.username)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
elif dbo.db_type == "MongoDB":
|
||||
ok, msg = drop_mongodb_database(dbo.name, dbo.username)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=msg)
|
||||
await db.delete(dbo)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Database deleted"}
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def database_count(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get database count"""
|
||||
result = await db.execute(select(func.count()).select_from(Database))
|
||||
return {"count": result.scalar() or 0}
|
||||
|
||||
|
||||
@router.post("/{db_id}/backup")
|
||||
async def database_backup(
|
||||
db_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create database backup (MySQL: mysqldump, PostgreSQL: pg_dump, MongoDB: mongodump)"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
if dbo.db_type not in ("MySQL", "PostgreSQL", "MongoDB"):
|
||||
raise HTTPException(status_code=400, detail="Backup not supported for this database type")
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = os.path.join(cfg["backup_path"], "database")
|
||||
if dbo.db_type == "MySQL":
|
||||
ok, msg, filename = backup_mysql_database(dbo.name, backup_dir)
|
||||
elif dbo.db_type == "PostgreSQL":
|
||||
ok, msg, filename = backup_postgresql_database(dbo.name, backup_dir)
|
||||
else:
|
||||
ok, msg, filename = backup_mongodb_database(dbo.name, backup_dir)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=msg)
|
||||
send_email(
|
||||
subject=f"YakPanel - Database backup: {dbo.name}",
|
||||
body=f"Backup completed: {filename}\nDatabase: {dbo.name}",
|
||||
)
|
||||
return {"status": True, "msg": "Backup created", "filename": filename}
|
||||
|
||||
|
||||
@router.get("/{db_id}/backups")
|
||||
async def database_backups_list(
|
||||
db_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List backups for a database"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = os.path.join(cfg["backup_path"], "database")
|
||||
if not os.path.isdir(backup_dir):
|
||||
return {"backups": []}
|
||||
prefix = f"{dbo.name}_"
|
||||
backups = []
|
||||
for f in os.listdir(backup_dir):
|
||||
if f.startswith(prefix) and f.endswith(".sql.gz"):
|
||||
p = os.path.join(backup_dir, f)
|
||||
backups.append({"filename": f, "size": os.path.getsize(p) if os.path.isfile(p) else 0})
|
||||
backups.sort(key=lambda x: x["filename"], reverse=True)
|
||||
return {"backups": backups}
|
||||
|
||||
|
||||
@router.get("/{db_id}/backups/download")
|
||||
async def database_backup_download(
|
||||
db_id: int,
|
||||
file: str = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download database backup"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{dbo.name}_"):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
if not (file.endswith(".sql.gz") or file.endswith(".tar.gz")):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
cfg = get_runtime_config()
|
||||
path = os.path.join(cfg["backup_path"], "database", file)
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
return FileResponse(path, filename=file)
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
filename: str
|
||||
|
||||
|
||||
@router.post("/{db_id}/restore")
|
||||
async def database_restore(
|
||||
db_id: int,
|
||||
body: RestoreRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Restore database from backup"""
|
||||
result = await db.execute(select(Database).where(Database.id == db_id))
|
||||
dbo = result.scalar_one_or_none()
|
||||
if not dbo:
|
||||
raise HTTPException(status_code=404, detail="Database not found")
|
||||
file = body.filename
|
||||
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{dbo.name}_"):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
valid_ext = file.endswith(".sql.gz") or file.endswith(".tar.gz")
|
||||
if not valid_ext:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
if dbo.db_type in ("MySQL", "PostgreSQL") and not file.endswith(".sql.gz"):
|
||||
raise HTTPException(status_code=400, detail="Wrong backup format for this database type")
|
||||
if dbo.db_type == "MongoDB" and not file.endswith(".tar.gz"):
|
||||
raise HTTPException(status_code=400, detail="Wrong backup format for MongoDB")
|
||||
cfg = get_runtime_config()
|
||||
backup_path = os.path.join(cfg["backup_path"], "database", file)
|
||||
if dbo.db_type == "MySQL":
|
||||
ok, msg = restore_mysql_database(dbo.name, backup_path)
|
||||
elif dbo.db_type == "PostgreSQL":
|
||||
ok, msg = restore_postgresql_database(dbo.name, backup_path)
|
||||
else:
|
||||
ok, msg = restore_mongodb_database(dbo.name, backup_path)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=msg)
|
||||
return {"status": True, "msg": "Restored"}
|
||||
162
YakPanel-server/backend/app/api/docker.py
Normal file
162
YakPanel-server/backend/app/api/docker.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""YakPanel - Docker API - list/start/stop containers via docker CLI"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/docker", tags=["docker"])
|
||||
|
||||
|
||||
class RunContainerRequest(BaseModel):
|
||||
image: str
|
||||
name: str = ""
|
||||
ports: str = ""
|
||||
cmd: str = ""
|
||||
|
||||
|
||||
@router.get("/containers")
|
||||
async def docker_containers(current_user: User = Depends(get_current_user)):
|
||||
"""List Docker containers (docker ps -a)"""
|
||||
out, err = exec_shell_sync(
|
||||
'docker ps -a --format "{{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}\t{{.Ports}}"',
|
||||
timeout=10,
|
||||
)
|
||||
if err and "Cannot connect" in err:
|
||||
return {"containers": [], "error": "Docker not available"}
|
||||
containers = []
|
||||
for line in out.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t", 4)
|
||||
if len(parts) >= 5:
|
||||
containers.append({
|
||||
"id": parts[0][:12],
|
||||
"id_full": parts[0],
|
||||
"image": parts[1],
|
||||
"status": parts[2],
|
||||
"names": parts[3],
|
||||
"ports": parts[4],
|
||||
})
|
||||
elif len(parts) >= 4:
|
||||
containers.append({
|
||||
"id": parts[0][:12],
|
||||
"id_full": parts[0],
|
||||
"image": parts[1],
|
||||
"status": parts[2],
|
||||
"names": parts[3],
|
||||
"ports": "",
|
||||
})
|
||||
return {"containers": containers}
|
||||
|
||||
|
||||
@router.get("/images")
|
||||
async def docker_images(current_user: User = Depends(get_current_user)):
|
||||
"""List Docker images (docker images)"""
|
||||
out, err = exec_shell_sync(
|
||||
'docker images --format "{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}"',
|
||||
timeout=10,
|
||||
)
|
||||
if err and "Cannot connect" in err:
|
||||
return {"images": [], "error": "Docker not available"}
|
||||
images = []
|
||||
for line in out.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t", 3)
|
||||
if len(parts) >= 4:
|
||||
images.append({
|
||||
"repository": parts[0],
|
||||
"tag": parts[1],
|
||||
"id": parts[2],
|
||||
"size": parts[3],
|
||||
})
|
||||
return {"images": images}
|
||||
|
||||
|
||||
@router.post("/pull")
|
||||
async def docker_pull(
|
||||
image: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Pull Docker image (docker pull)"""
|
||||
if not image or " " in image or "'" in image or '"' in image:
|
||||
raise HTTPException(status_code=400, detail="Invalid image name")
|
||||
out, err = exec_shell_sync(f"docker pull {image}", timeout=600)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Pulled"}
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def docker_run(
|
||||
body: RunContainerRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Run a new container (docker run -d)"""
|
||||
image = (body.image or "").strip()
|
||||
if not image:
|
||||
raise HTTPException(status_code=400, detail="Image required")
|
||||
if " " in image or "'" in image or '"' in image:
|
||||
raise HTTPException(status_code=400, detail="Invalid image name")
|
||||
cmd = f"docker run -d {image}"
|
||||
if body.name:
|
||||
name = body.name.strip().replace(" ", "-")
|
||||
if name and all(c.isalnum() or c in "-_" for c in name):
|
||||
cmd += f" --name {name}"
|
||||
if body.ports:
|
||||
for p in body.ports.replace(",", " ").split():
|
||||
p = p.strip()
|
||||
if p:
|
||||
cmd += f" -p {p}" if ":" in p else f" -p {p}:{p}"
|
||||
if body.cmd:
|
||||
cmd += f" {body.cmd}"
|
||||
out, err = exec_shell_sync(cmd, timeout=60)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Container started", "id": out.strip()[:12]}
|
||||
|
||||
|
||||
@router.post("/{container_id}/start")
|
||||
async def docker_start(
|
||||
container_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Start container"""
|
||||
if " " in container_id or "'" in container_id or '"' in container_id:
|
||||
raise HTTPException(status_code=400, detail="Invalid container ID")
|
||||
out, err = exec_shell_sync(f"docker start {container_id}", timeout=30)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Started"}
|
||||
|
||||
|
||||
@router.post("/{container_id}/stop")
|
||||
async def docker_stop(
|
||||
container_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Stop container"""
|
||||
if " " in container_id or "'" in container_id or '"' in container_id:
|
||||
raise HTTPException(status_code=400, detail="Invalid container ID")
|
||||
out, err = exec_shell_sync(f"docker stop {container_id}", timeout=30)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Stopped"}
|
||||
|
||||
|
||||
@router.post("/{container_id}/restart")
|
||||
async def docker_restart(
|
||||
container_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Restart container"""
|
||||
if " " in container_id or "'" in container_id or '"' in container_id:
|
||||
raise HTTPException(status_code=400, detail="Invalid container ID")
|
||||
out, err = exec_shell_sync(f"docker restart {container_id}", timeout=30)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Restarted"}
|
||||
240
YakPanel-server/backend/app/api/files.py
Normal file
240
YakPanel-server/backend/app/api/files.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""YakPanel - File manager API"""
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import read_file, write_file, path_safe_check
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/files", tags=["files"])
|
||||
|
||||
|
||||
def _resolve_path(path: str) -> str:
|
||||
"""Resolve and validate path within allowed roots (cross-platform)"""
|
||||
cfg = get_runtime_config()
|
||||
www_root = os.path.abspath(cfg["www_root"])
|
||||
setup_path = os.path.abspath(cfg["setup_path"])
|
||||
allowed = [www_root, setup_path]
|
||||
if os.name != "nt":
|
||||
allowed.append(os.path.abspath("/www"))
|
||||
if ".." in path:
|
||||
raise HTTPException(status_code=401, detail="Path traversal not allowed")
|
||||
norm_path = path.strip().replace("\\", "/").strip("/")
|
||||
# Root or www_root-style path
|
||||
if not norm_path or norm_path in ("www", "www/wwwroot", "wwwroot"):
|
||||
full = www_root
|
||||
elif norm_path.startswith("www/wwwroot/"):
|
||||
full = os.path.abspath(os.path.join(www_root, norm_path[12:]))
|
||||
else:
|
||||
full = os.path.abspath(os.path.join(www_root, norm_path))
|
||||
if not any(
|
||||
full == r or (full + os.sep).startswith(r + os.sep)
|
||||
for r in allowed
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Path not allowed")
|
||||
return full
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def files_list(
|
||||
path: str = "/",
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List directory contents"""
|
||||
try:
|
||||
full = _resolve_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isdir(full):
|
||||
raise HTTPException(status_code=401, detail="Not a directory")
|
||||
items = []
|
||||
for name in os.listdir(full):
|
||||
item_path = os.path.join(full, name)
|
||||
try:
|
||||
stat = os.stat(item_path)
|
||||
items.append({
|
||||
"name": name,
|
||||
"is_dir": os.path.isdir(item_path),
|
||||
"size": stat.st_size if os.path.isfile(item_path) else 0,
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
return {"path": path, "items": items}
|
||||
|
||||
|
||||
@router.get("/read")
|
||||
async def files_read(
|
||||
path: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Read file content"""
|
||||
try:
|
||||
full = _resolve_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isfile(full):
|
||||
raise HTTPException(status_code=404, detail="Not a file")
|
||||
content = read_file(full)
|
||||
if content is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to read file")
|
||||
return {"path": path, "content": content}
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
async def files_download(
|
||||
path: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Download file"""
|
||||
try:
|
||||
full = _resolve_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isfile(full):
|
||||
raise HTTPException(status_code=404, detail="Not a file")
|
||||
return FileResponse(full, filename=os.path.basename(full))
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def files_upload(
|
||||
path: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload file to directory"""
|
||||
try:
|
||||
full = _resolve_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isdir(full):
|
||||
raise HTTPException(status_code=400, detail="Path must be a directory")
|
||||
filename = file.filename or "upload"
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
dest = os.path.join(full, filename)
|
||||
content = await file.read()
|
||||
if not write_file(dest, content, "wb"):
|
||||
raise HTTPException(status_code=500, detail="Failed to write file")
|
||||
return {"status": True, "msg": "Uploaded", "path": path + "/" + filename}
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
|
||||
|
||||
class RenameRequest(BaseModel):
|
||||
path: str
|
||||
old_name: str
|
||||
new_name: str
|
||||
|
||||
|
||||
class DeleteRequest(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
is_dir: bool
|
||||
|
||||
|
||||
@router.post("/mkdir")
|
||||
async def files_mkdir(
|
||||
body: MkdirRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create directory"""
|
||||
try:
|
||||
parent = _resolve_path(body.path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isdir(parent):
|
||||
raise HTTPException(status_code=400, detail="Parent must be a directory")
|
||||
if not body.name or ".." in body.name or "/" in body.name or "\\" in body.name:
|
||||
raise HTTPException(status_code=400, detail="Invalid directory name")
|
||||
if not path_safe_check(body.name):
|
||||
raise HTTPException(status_code=400, detail="Invalid directory name")
|
||||
full = os.path.join(parent, body.name)
|
||||
if os.path.exists(full):
|
||||
raise HTTPException(status_code=400, detail="Already exists")
|
||||
try:
|
||||
os.makedirs(full, 0o755)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"status": True, "msg": "Created", "path": body.path.rstrip("/") + "/" + body.name}
|
||||
|
||||
|
||||
@router.post("/rename")
|
||||
async def files_rename(
|
||||
body: RenameRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Rename file or directory"""
|
||||
try:
|
||||
parent = _resolve_path(body.path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not body.old_name or not body.new_name:
|
||||
raise HTTPException(status_code=400, detail="Names required")
|
||||
for n in (body.old_name, body.new_name):
|
||||
if ".." in n or "/" in n or "\\" in n or not path_safe_check(n):
|
||||
raise HTTPException(status_code=400, detail="Invalid name")
|
||||
old_full = os.path.join(parent, body.old_name)
|
||||
new_full = os.path.join(parent, body.new_name)
|
||||
if not os.path.exists(old_full):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if os.path.exists(new_full):
|
||||
raise HTTPException(status_code=400, detail="Target already exists")
|
||||
try:
|
||||
os.rename(old_full, new_full)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"status": True, "msg": "Renamed"}
|
||||
|
||||
|
||||
@router.post("/delete")
|
||||
async def files_delete(
|
||||
body: DeleteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete file or directory"""
|
||||
try:
|
||||
parent = _resolve_path(body.path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not body.name or ".." in body.name or "/" in body.name or "\\" in body.name:
|
||||
raise HTTPException(status_code=400, detail="Invalid name")
|
||||
full = os.path.join(parent, body.name)
|
||||
if not os.path.exists(full):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
try:
|
||||
if body.is_dir:
|
||||
import shutil
|
||||
shutil.rmtree(full)
|
||||
else:
|
||||
os.remove(full)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"status": True, "msg": "Deleted"}
|
||||
|
||||
|
||||
class WriteFileRequest(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
@router.post("/write")
|
||||
async def files_write(
|
||||
body: WriteFileRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Write text file content"""
|
||||
try:
|
||||
full = _resolve_path(body.path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if os.path.isdir(full):
|
||||
raise HTTPException(status_code=400, detail="Cannot write to directory")
|
||||
if not write_file(full, body.content):
|
||||
raise HTTPException(status_code=500, detail="Failed to write file")
|
||||
return {"status": True, "msg": "Saved"}
|
||||
83
YakPanel-server/backend/app/api/firewall.py
Normal file
83
YakPanel-server/backend/app/api/firewall.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""YakPanel - Firewall API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.firewall import FirewallRule
|
||||
|
||||
router = APIRouter(prefix="/firewall", tags=["firewall"])
|
||||
|
||||
|
||||
class CreateFirewallRuleRequest(BaseModel):
|
||||
port: str
|
||||
protocol: str = "tcp"
|
||||
action: str = "accept"
|
||||
ps: str = ""
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def firewall_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List firewall rules"""
|
||||
result = await db.execute(select(FirewallRule).order_by(FirewallRule.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "port": r.port, "protocol": r.protocol, "action": r.action, "ps": r.ps} for r in rows]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def firewall_create(
|
||||
body: CreateFirewallRuleRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add firewall rule (stored in panel; use Apply to UFW to sync)"""
|
||||
if not body.port or len(body.port) > 32:
|
||||
raise HTTPException(status_code=400, detail="Invalid port")
|
||||
rule = FirewallRule(port=body.port, protocol=body.protocol, action=body.action, ps=body.ps)
|
||||
db.add(rule)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Rule added", "id": rule.id}
|
||||
|
||||
|
||||
@router.delete("/{rule_id}")
|
||||
async def firewall_delete(
|
||||
rule_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete firewall rule"""
|
||||
result = await db.execute(select(FirewallRule).where(FirewallRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Rule deleted"}
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def firewall_apply(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Apply firewall rules to ufw (runs ufw allow/deny for each rule)"""
|
||||
result = await db.execute(select(FirewallRule).order_by(FirewallRule.id))
|
||||
rules = result.scalars().all()
|
||||
errors = []
|
||||
for r in rules:
|
||||
port_proto = f"{r.port}/{r.protocol}"
|
||||
action = "allow" if r.action == "accept" else "deny"
|
||||
cmd = f"ufw {action} {port_proto}"
|
||||
out, err = exec_shell_sync(cmd, timeout=10)
|
||||
if err and "error" in err.lower() and "already" not in err.lower():
|
||||
errors.append(f"{port_proto}: {err.strip()}")
|
||||
if errors:
|
||||
raise HTTPException(status_code=500, detail="; ".join(errors))
|
||||
return {"status": True, "msg": "Rules applied", "count": len(rules)}
|
||||
113
YakPanel-server/backend/app/api/ftp.py
Normal file
113
YakPanel-server/backend/app/api/ftp.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""YakPanel - FTP API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_password_hash
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.ftp import Ftp
|
||||
from app.services.ftp_service import create_ftp_user, delete_ftp_user, update_ftp_password
|
||||
|
||||
router = APIRouter(prefix="/ftp", tags=["ftp"])
|
||||
|
||||
|
||||
class CreateFtpRequest(BaseModel):
|
||||
name: str
|
||||
password: str
|
||||
path: str
|
||||
pid: int = 0
|
||||
ps: str = ""
|
||||
|
||||
|
||||
class UpdateFtpPasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def ftp_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List FTP accounts"""
|
||||
result = await db.execute(select(Ftp).order_by(Ftp.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "name": r.name, "path": r.path, "ps": r.ps} for r in rows]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def ftp_create(
|
||||
body: CreateFtpRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create FTP account (panel + Pure-FTPd when available)"""
|
||||
result = await db.execute(select(Ftp).where(Ftp.name == body.name))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="FTP account already exists")
|
||||
ok, msg = create_ftp_user(body.name, body.password, body.path)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"FTP: {msg}")
|
||||
ftp = Ftp(
|
||||
name=body.name,
|
||||
password=get_password_hash(body.password),
|
||||
path=body.path,
|
||||
pid=body.pid,
|
||||
ps=body.ps,
|
||||
)
|
||||
db.add(ftp)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "FTP account created", "id": ftp.id}
|
||||
|
||||
|
||||
@router.put("/{ftp_id}/password")
|
||||
async def ftp_update_password(
|
||||
ftp_id: int,
|
||||
body: UpdateFtpPasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update FTP account password"""
|
||||
result = await db.execute(select(Ftp).where(Ftp.id == ftp_id))
|
||||
ftp = result.scalar_one_or_none()
|
||||
if not ftp:
|
||||
raise HTTPException(status_code=404, detail="FTP account not found")
|
||||
if not body.password or len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
ok, msg = update_ftp_password(ftp.name, body.password)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"FTP: {msg}")
|
||||
ftp.password = get_password_hash(body.password)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Password updated"}
|
||||
|
||||
|
||||
@router.delete("/{ftp_id}")
|
||||
async def ftp_delete(
|
||||
ftp_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete FTP account (panel + Pure-FTPd when available)"""
|
||||
result = await db.execute(select(Ftp).where(Ftp.id == ftp_id))
|
||||
ftp = result.scalar_one_or_none()
|
||||
if not ftp:
|
||||
raise HTTPException(status_code=404, detail="FTP account not found")
|
||||
ok, msg = delete_ftp_user(ftp.name)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"FTP: {msg}")
|
||||
await db.delete(ftp)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "FTP account deleted"}
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
async def ftp_count(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get FTP count"""
|
||||
result = await db.execute(select(func.count()).select_from(Ftp))
|
||||
return {"count": result.scalar() or 0}
|
||||
80
YakPanel-server/backend/app/api/logs.py
Normal file
80
YakPanel-server/backend/app/api/logs.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""YakPanel - Logs viewer API"""
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import read_file
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
|
||||
def _resolve_log_path(path: str) -> str:
|
||||
"""Resolve path within www_logs only"""
|
||||
if ".." in path:
|
||||
raise HTTPException(status_code=401, detail="Path traversal not allowed")
|
||||
cfg = get_runtime_config()
|
||||
logs_root = os.path.abspath(cfg["www_logs"])
|
||||
path = path.strip().replace("\\", "/").lstrip("/")
|
||||
if not path:
|
||||
return logs_root
|
||||
full = os.path.abspath(os.path.join(logs_root, path))
|
||||
if not (full == logs_root or full.startswith(logs_root + os.sep)):
|
||||
raise HTTPException(status_code=403, detail="Path not allowed")
|
||||
return full
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def logs_list(
|
||||
path: str = "/",
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List log files and directories under www_logs"""
|
||||
try:
|
||||
full = _resolve_log_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isdir(full):
|
||||
raise HTTPException(status_code=400, detail="Not a directory")
|
||||
items = []
|
||||
for name in sorted(os.listdir(full)):
|
||||
item_path = os.path.join(full, name)
|
||||
try:
|
||||
stat = os.stat(item_path)
|
||||
items.append({
|
||||
"name": name,
|
||||
"is_dir": os.path.isdir(item_path),
|
||||
"size": stat.st_size if os.path.isfile(item_path) else 0,
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
rel = path.rstrip("/") or "/"
|
||||
return {"path": rel, "items": items}
|
||||
|
||||
|
||||
@router.get("/read")
|
||||
async def logs_read(
|
||||
path: str,
|
||||
tail: int = Query(default=1000, ge=1, le=100000),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Read log file content (last N lines)"""
|
||||
try:
|
||||
full = _resolve_log_path(path)
|
||||
except HTTPException:
|
||||
raise
|
||||
if not os.path.isfile(full):
|
||||
raise HTTPException(status_code=404, detail="Not a file")
|
||||
content = read_file(full)
|
||||
if content is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to read file")
|
||||
if isinstance(content, bytes):
|
||||
try:
|
||||
content = content.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Binary file")
|
||||
lines = content.splitlines()
|
||||
if len(lines) > tail:
|
||||
lines = lines[-tail:]
|
||||
return {"path": path, "content": "\n".join(lines), "total_lines": len(content.splitlines())}
|
||||
65
YakPanel-server/backend/app/api/monitor.py
Normal file
65
YakPanel-server/backend/app/api/monitor.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""YakPanel - Monitor API"""
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/monitor", tags=["monitor"])
|
||||
|
||||
|
||||
@router.get("/system")
|
||||
async def monitor_system(current_user: User = Depends(get_current_user)):
|
||||
"""Get system stats"""
|
||||
cpu = psutil.cpu_percent(interval=1)
|
||||
mem = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage("/")
|
||||
return {
|
||||
"cpu_percent": cpu,
|
||||
"memory_percent": mem.percent,
|
||||
"memory_used_mb": round(mem.used / 1024 / 1024, 1),
|
||||
"memory_total_mb": round(mem.total / 1024 / 1024, 1),
|
||||
"disk_percent": disk.percent,
|
||||
"disk_used_gb": round(disk.used / 1024 / 1024 / 1024, 2),
|
||||
"disk_total_gb": round(disk.total / 1024 / 1024 / 1024, 2),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/processes")
|
||||
async def monitor_processes(
|
||||
current_user: User = Depends(get_current_user),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""Get top processes by CPU usage"""
|
||||
procs = []
|
||||
for p in psutil.process_iter(["pid", "name", "username", "cpu_percent", "memory_percent", "status"]):
|
||||
try:
|
||||
info = p.info
|
||||
cpu = info.get("cpu_percent") or 0
|
||||
mem = info.get("memory_percent") or 0
|
||||
procs.append({
|
||||
"pid": info.get("pid"),
|
||||
"name": info.get("name") or "",
|
||||
"username": info.get("username") or "",
|
||||
"cpu_percent": round(cpu, 1),
|
||||
"memory_percent": round(mem, 1),
|
||||
"status": info.get("status") or "",
|
||||
})
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
procs.sort(key=lambda x: (x["cpu_percent"] or 0), reverse=True)
|
||||
return {"processes": procs[:limit]}
|
||||
|
||||
|
||||
@router.get("/network")
|
||||
async def monitor_network(current_user: User = Depends(get_current_user)):
|
||||
"""Get network I/O stats"""
|
||||
net = psutil.net_io_counters()
|
||||
return {
|
||||
"bytes_sent": net.bytes_sent,
|
||||
"bytes_recv": net.bytes_recv,
|
||||
"packets_sent": net.packets_sent,
|
||||
"packets_recv": net.packets_recv,
|
||||
"bytes_sent_mb": round(net.bytes_sent / 1024 / 1024, 2),
|
||||
"bytes_recv_mb": round(net.bytes_recv / 1024 / 1024, 2),
|
||||
}
|
||||
136
YakPanel-server/backend/app/api/node.py
Normal file
136
YakPanel-server/backend/app/api/node.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""YakPanel - Node.js / PM2 API"""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/node", tags=["node"])
|
||||
|
||||
|
||||
class AddProcessRequest(BaseModel):
|
||||
script: str
|
||||
name: str = ""
|
||||
|
||||
|
||||
@router.get("/processes")
|
||||
async def node_processes(current_user: User = Depends(get_current_user)):
|
||||
"""List PM2 processes (pm2 jlist)"""
|
||||
out, err = exec_shell_sync("pm2 jlist", timeout=10)
|
||||
if err and "not found" in err.lower():
|
||||
return {"processes": [], "error": "PM2 not installed"}
|
||||
try:
|
||||
data = json.loads(out) if out.strip() else []
|
||||
processes = []
|
||||
now_ms = int(time.time() * 1000)
|
||||
for p in data if isinstance(data, list) else []:
|
||||
name = p.get("name", "")
|
||||
pm2_env = p.get("pm2_env", {})
|
||||
start_ms = pm2_env.get("pm_uptime", 0)
|
||||
uptime_ms = (now_ms - start_ms) if start_ms and pm2_env.get("status") == "online" else 0
|
||||
processes.append({
|
||||
"id": p.get("pm_id"),
|
||||
"name": name,
|
||||
"status": pm2_env.get("status", "unknown"),
|
||||
"pid": p.get("pid"),
|
||||
"uptime": uptime_ms,
|
||||
"restarts": pm2_env.get("restart_time", 0),
|
||||
"memory": p.get("monit", {}).get("memory", 0),
|
||||
"cpu": p.get("monit", {}).get("cpu", 0),
|
||||
})
|
||||
return {"processes": processes}
|
||||
except json.JSONDecodeError:
|
||||
return {"processes": [], "error": "Failed to parse PM2 output"}
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
async def node_add(
|
||||
body: AddProcessRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add and start a new PM2 process (pm2 start script --name name)"""
|
||||
script = (body.script or "").strip()
|
||||
if not script:
|
||||
raise HTTPException(status_code=400, detail="Script path required")
|
||||
if ".." in script or ";" in script or "|" in script or "`" in script:
|
||||
raise HTTPException(status_code=400, detail="Invalid script path")
|
||||
name = (body.name or "").strip()
|
||||
if name and ("'" in name or '"' in name or ";" in name):
|
||||
raise HTTPException(status_code=400, detail="Invalid process name")
|
||||
cmd = f"pm2 start {script}"
|
||||
if name:
|
||||
cmd += f" --name '{name}'"
|
||||
out, err = exec_shell_sync(cmd, timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Process started"}
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def node_version(current_user: User = Depends(get_current_user)):
|
||||
"""Get Node.js version"""
|
||||
out, err = exec_shell_sync("node -v", timeout=5)
|
||||
version = out.strip() if out else ""
|
||||
if err and "not found" in err.lower():
|
||||
return {"version": None, "error": "Node.js not installed"}
|
||||
return {"version": version or None}
|
||||
|
||||
|
||||
@router.post("/{proc_id}/start")
|
||||
async def node_start(
|
||||
proc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Start PM2 process"""
|
||||
if not re.match(r"^\d+$", proc_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid process ID")
|
||||
out, err = exec_shell_sync(f"pm2 start {proc_id}", timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Started"}
|
||||
|
||||
|
||||
@router.post("/{proc_id}/stop")
|
||||
async def node_stop(
|
||||
proc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Stop PM2 process"""
|
||||
if not re.match(r"^\d+$", proc_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid process ID")
|
||||
out, err = exec_shell_sync(f"pm2 stop {proc_id}", timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Stopped"}
|
||||
|
||||
|
||||
@router.post("/{proc_id}/restart")
|
||||
async def node_restart(
|
||||
proc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Restart PM2 process"""
|
||||
if not re.match(r"^\d+$", proc_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid process ID")
|
||||
out, err = exec_shell_sync(f"pm2 restart {proc_id}", timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Restarted"}
|
||||
|
||||
|
||||
@router.delete("/{proc_id}")
|
||||
async def node_delete(
|
||||
proc_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete PM2 process"""
|
||||
if not re.match(r"^\d+$", proc_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid process ID")
|
||||
out, err = exec_shell_sync(f"pm2 delete {proc_id}", timeout=15)
|
||||
if err and "error" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Deleted"}
|
||||
127
YakPanel-server/backend/app/api/plugin.py
Normal file
127
YakPanel-server/backend/app/api/plugin.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""YakPanel - Plugin / Extensions API"""
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.plugin import CustomPlugin
|
||||
|
||||
router = APIRouter(prefix="/plugin", tags=["plugin"])
|
||||
|
||||
# Built-in extensions (features) - always enabled
|
||||
BUILTIN_PLUGINS = [
|
||||
{"id": "backup", "name": "Backup", "version": "1.0", "desc": "Site and database backup/restore", "enabled": True, "builtin": True},
|
||||
{"id": "ssl", "name": "SSL/ACME", "version": "1.0", "desc": "Let's Encrypt certificates", "enabled": True, "builtin": True},
|
||||
{"id": "docker", "name": "Docker", "version": "1.0", "desc": "Container management", "enabled": True, "builtin": True},
|
||||
{"id": "node", "name": "Node.js", "version": "1.0", "desc": "PM2 process manager", "enabled": True, "builtin": True},
|
||||
{"id": "services", "name": "Services", "version": "1.0", "desc": "System service control", "enabled": True, "builtin": True},
|
||||
{"id": "logs", "name": "Logs", "version": "1.0", "desc": "Log file viewer", "enabled": True, "builtin": True},
|
||||
{"id": "terminal", "name": "Terminal", "version": "1.0", "desc": "Web terminal", "enabled": True, "builtin": True},
|
||||
{"id": "monitor", "name": "Monitor", "version": "1.0", "desc": "System monitoring", "enabled": True, "builtin": True},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def plugin_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List built-in + custom plugins"""
|
||||
result = await db.execute(select(CustomPlugin).order_by(CustomPlugin.id))
|
||||
custom = result.scalars().all()
|
||||
builtin_ids = {p["id"] for p in BUILTIN_PLUGINS}
|
||||
plugins = list(BUILTIN_PLUGINS)
|
||||
for c in custom:
|
||||
plugins.append({
|
||||
"id": c.plugin_id,
|
||||
"name": c.name,
|
||||
"version": c.version,
|
||||
"desc": c.desc,
|
||||
"enabled": c.enabled,
|
||||
"builtin": False,
|
||||
"db_id": c.id,
|
||||
})
|
||||
return {"plugins": plugins}
|
||||
|
||||
|
||||
class AddPluginRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.post("/add-from-url")
|
||||
async def plugin_add_from_url(
|
||||
body: AddPluginRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a third-party plugin from a JSON manifest URL"""
|
||||
url = (body.url or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="URL required")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="Only http/https URLs allowed")
|
||||
if not parsed.netloc or parsed.netloc.startswith("127.") or parsed.netloc == "localhost":
|
||||
raise HTTPException(status_code=400, detail="Invalid URL")
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "YakPanel/1.0"})
|
||||
with urlopen(req, timeout=10) as r:
|
||||
data = r.read(64 * 1024).decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch: {str(e)[:100]}")
|
||||
try:
|
||||
manifest = json.loads(data)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
|
||||
pid = (manifest.get("id") or "").strip()
|
||||
name = (manifest.get("name") or "").strip()
|
||||
if not pid or not name:
|
||||
raise HTTPException(status_code=400, detail="Manifest must have 'id' and 'name'")
|
||||
if not re.match(r"^[a-z0-9_-]+$", pid):
|
||||
raise HTTPException(status_code=400, detail="Plugin id must be alphanumeric, underscore, hyphen only")
|
||||
version = (manifest.get("version") or "1.0").strip()[:32]
|
||||
desc = (manifest.get("desc") or "").strip()[:512]
|
||||
# Check builtin conflict
|
||||
if any(p["id"] == pid for p in BUILTIN_PLUGINS):
|
||||
raise HTTPException(status_code=400, detail="Plugin id conflicts with built-in")
|
||||
# Check existing custom
|
||||
r = await db.execute(select(CustomPlugin).where(CustomPlugin.plugin_id == pid))
|
||||
if r.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Plugin already installed")
|
||||
cp = CustomPlugin(
|
||||
plugin_id=pid,
|
||||
name=name,
|
||||
version=version,
|
||||
desc=desc,
|
||||
source_url=url[:512],
|
||||
enabled=True,
|
||||
)
|
||||
db.add(cp)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Plugin added", "id": cp.plugin_id}
|
||||
|
||||
|
||||
@router.delete("/{plugin_id}")
|
||||
async def plugin_delete(
|
||||
plugin_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove a custom plugin (built-in plugins cannot be removed)"""
|
||||
if any(p["id"] == plugin_id for p in BUILTIN_PLUGINS):
|
||||
raise HTTPException(status_code=400, detail="Cannot remove built-in plugin")
|
||||
result = await db.execute(select(CustomPlugin).where(CustomPlugin.plugin_id == plugin_id))
|
||||
cp = result.scalar_one_or_none()
|
||||
if not cp:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
await db.delete(cp)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Plugin removed"}
|
||||
310
YakPanel-server/backend/app/api/public_installer.py
Normal file
310
YakPanel-server/backend/app/api/public_installer.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""Optional remote SSH installer — disabled by default (ENABLE_REMOTE_INSTALLER)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Annotated, Any, Literal, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import asyncssh
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/public-install", tags=["public-install"])
|
||||
|
||||
_jobs: dict[str, dict[str, Any]] = {}
|
||||
_rate_buckets: dict[str, list[float]] = defaultdict(list)
|
||||
_jobs_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _safe_host(host: str) -> str:
|
||||
h = host.strip()
|
||||
if not h or len(h) > 253:
|
||||
raise ValueError("invalid host")
|
||||
if re.search(r"[\s;|&$`\\'\"\n<>()]", h):
|
||||
raise ValueError("invalid host characters")
|
||||
try:
|
||||
ipaddress.ip_address(h)
|
||||
return h
|
||||
except ValueError:
|
||||
pass
|
||||
if not re.match(r"^[a-zA-Z0-9.\-]+$", h):
|
||||
raise ValueError("invalid hostname")
|
||||
return h
|
||||
|
||||
|
||||
def _validate_install_url(url: str) -> str:
|
||||
p = urlparse(url.strip())
|
||||
if p.scheme != "https":
|
||||
raise ValueError("install_url must use https")
|
||||
if not p.netloc or p.username is not None or p.password is not None:
|
||||
raise ValueError("invalid install URL")
|
||||
return url.strip()
|
||||
|
||||
|
||||
async def _target_ip_allowed(host: str) -> bool:
|
||||
settings = get_settings()
|
||||
raw = settings.remote_install_allowed_target_cidrs.strip()
|
||||
if not raw:
|
||||
return True
|
||||
cidrs: list = []
|
||||
for part in raw.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
cidrs.append(ipaddress.ip_network(part, strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
if not cidrs:
|
||||
return True
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def resolve() -> list[str]:
|
||||
out: list[str] = []
|
||||
try:
|
||||
for fam, _ty, _pr, _cn, sa in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM):
|
||||
out.append(sa[0])
|
||||
except socket.gaierror:
|
||||
return []
|
||||
return out
|
||||
|
||||
addrs = await loop.run_in_executor(None, resolve)
|
||||
if not addrs:
|
||||
return False
|
||||
for addr in addrs:
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
if any(ip in net for net in cidrs):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _check_rate_limit(client_ip: str) -> None:
|
||||
settings = get_settings()
|
||||
lim = settings.remote_install_rate_limit_per_ip
|
||||
if lim <= 0:
|
||||
return
|
||||
window_sec = max(1, settings.remote_install_rate_window_minutes) * 60
|
||||
now = time.monotonic()
|
||||
bucket = _rate_buckets[client_ip]
|
||||
bucket[:] = [t for t in bucket if now - t < window_sec]
|
||||
if len(bucket) >= lim:
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded. Try again later.")
|
||||
bucket.append(now)
|
||||
|
||||
|
||||
class AuthKey(BaseModel):
|
||||
type: Literal["key"] = "key"
|
||||
private_key: str = Field(..., min_length=1)
|
||||
passphrase: Optional[str] = None
|
||||
|
||||
|
||||
class AuthPassword(BaseModel):
|
||||
type: Literal["password"] = "password"
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class CreateJobRequest(BaseModel):
|
||||
host: str
|
||||
port: int = Field(default=22, ge=1, le=65535)
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
auth: Annotated[Union[AuthKey, AuthPassword], Field(discriminator="type")]
|
||||
install_url: Optional[str] = None
|
||||
|
||||
@field_validator("host")
|
||||
@classmethod
|
||||
def host_ok(cls, v: str) -> str:
|
||||
return _safe_host(v)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def user_ok(cls, v: str) -> str:
|
||||
u = v.strip()
|
||||
if re.search(r"[\s;|&$`\\'\"\n<>]", u):
|
||||
raise ValueError("invalid username")
|
||||
return u
|
||||
|
||||
@field_validator("install_url")
|
||||
@classmethod
|
||||
def url_ok(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return _validate_install_url(v)
|
||||
|
||||
|
||||
class CreateJobResponse(BaseModel):
|
||||
job_id: str
|
||||
|
||||
|
||||
def _broadcast(job: dict[str, Any], msg: str) -> None:
|
||||
for q in list(job.get("channels", ())):
|
||||
try:
|
||||
q.put_nowait(msg)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def installer_config():
|
||||
s = get_settings()
|
||||
return {
|
||||
"enabled": s.enable_remote_installer,
|
||||
"default_install_url": s.remote_install_default_url,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/jobs", response_model=CreateJobResponse)
|
||||
async def create_job(body: CreateJobRequest, request: Request):
|
||||
settings = get_settings()
|
||||
if not settings.enable_remote_installer:
|
||||
raise HTTPException(status_code=403, detail="Remote installer is disabled")
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
_check_rate_limit(client_ip)
|
||||
|
||||
host = body.host
|
||||
if not await _target_ip_allowed(host):
|
||||
raise HTTPException(status_code=400, detail="Target host is not in allowed CIDR list")
|
||||
|
||||
url = body.install_url or settings.remote_install_default_url
|
||||
try:
|
||||
url = _validate_install_url(url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
channels: set[asyncio.Queue] = set()
|
||||
|
||||
inner = f"curl -fsSL {shlex.quote(url)} | bash"
|
||||
if body.username == "root":
|
||||
remote_cmd = f"bash -lc {shlex.quote(inner)}"
|
||||
else:
|
||||
remote_cmd = f"sudo -n bash -lc {shlex.quote(inner)}"
|
||||
|
||||
auth_payload = body.auth
|
||||
|
||||
async def runner() -> None:
|
||||
async with _jobs_lock:
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
exit_code: Optional[int] = None
|
||||
|
||||
def broadcast(msg: str) -> None:
|
||||
_broadcast(job, msg)
|
||||
|
||||
try:
|
||||
connect_kw: dict[str, Any] = {
|
||||
"host": host,
|
||||
"port": body.port,
|
||||
"username": body.username,
|
||||
"known_hosts": None,
|
||||
"connect_timeout": 30,
|
||||
}
|
||||
if auth_payload.type == "key":
|
||||
try:
|
||||
key = asyncssh.import_private_key(
|
||||
auth_payload.private_key.encode(),
|
||||
passphrase=auth_payload.passphrase or None,
|
||||
)
|
||||
except Exception:
|
||||
broadcast(json.dumps({"type": "line", "text": "Invalid private key or passphrase"}))
|
||||
broadcast(json.dumps({"type": "done", "exit_code": -1}))
|
||||
return
|
||||
connect_kw["client_keys"] = [key]
|
||||
else:
|
||||
connect_kw["password"] = auth_payload.password
|
||||
|
||||
async with asyncssh.connect(**connect_kw) as conn:
|
||||
async with conn.create_process(remote_cmd) as proc:
|
||||
|
||||
async def pump(stream: Any, is_err: bool) -> None:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode(errors="replace").rstrip("\n\r")
|
||||
prefix = "[stderr] " if is_err else ""
|
||||
broadcast(json.dumps({"type": "line", "text": prefix + text}))
|
||||
|
||||
await asyncio.gather(
|
||||
pump(proc.stdout, False),
|
||||
pump(proc.stderr, True),
|
||||
)
|
||||
await proc.wait()
|
||||
exit_code = proc.exit_status
|
||||
except asyncssh.Error as e:
|
||||
msg = str(e).split("\n")[0][:240]
|
||||
broadcast(json.dumps({"type": "line", "text": "SSH error: " + msg}))
|
||||
except OSError as e:
|
||||
broadcast(json.dumps({"type": "line", "text": "Connection error: " + str(e)[:200]}))
|
||||
except Exception:
|
||||
broadcast(json.dumps({"type": "line", "text": "Unexpected installer error"}))
|
||||
|
||||
broadcast(json.dumps({"type": "done", "exit_code": exit_code if exit_code is not None else -1}))
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _purge() -> None:
|
||||
_jobs.pop(job_id, None)
|
||||
|
||||
loop.call_later(900, _purge)
|
||||
|
||||
async with _jobs_lock:
|
||||
_jobs[job_id] = {"channels": channels}
|
||||
task = asyncio.create_task(runner())
|
||||
_jobs[job_id]["task"] = task
|
||||
|
||||
return CreateJobResponse(job_id=job_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/{job_id}")
|
||||
async def job_ws(websocket: WebSocket, job_id: str):
|
||||
settings = get_settings()
|
||||
if not settings.enable_remote_installer:
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
async with _jobs_lock:
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
await websocket.send_text(json.dumps({"type": "line", "text": "Unknown or expired job_id"}))
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=500)
|
||||
job["channels"].add(q)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(q.get(), timeout=7200.0)
|
||||
except asyncio.TimeoutError:
|
||||
await websocket.send_text(json.dumps({"type": "line", "text": "… idle timeout"}))
|
||||
break
|
||||
await websocket.send_text(msg)
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
if data.get("type") == "done":
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
finally:
|
||||
job["channels"].discard(q)
|
||||
await websocket.close()
|
||||
101
YakPanel-server/backend/app/api/service.py
Normal file
101
YakPanel-server/backend/app/api/service.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""YakPanel - System services (systemctl)"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/service", tags=["service"])
|
||||
|
||||
# Common services (name -> systemd unit)
|
||||
SERVICES = [
|
||||
{"id": "nginx", "name": "Nginx", "unit": "nginx"},
|
||||
{"id": "mysql", "name": "MySQL", "unit": "mysql"},
|
||||
{"id": "mariadb", "name": "MariaDB", "unit": "mariadb"},
|
||||
{"id": "php-fpm", "name": "PHP-FPM", "unit": "php*-fpm"},
|
||||
{"id": "redis", "name": "Redis", "unit": "redis-server"},
|
||||
{"id": "pure-ftpd", "name": "Pure-FTPd", "unit": "pure-ftpd"},
|
||||
]
|
||||
|
||||
|
||||
def _get_unit(unit_pattern: str) -> str:
|
||||
"""Resolve unit pattern (e.g. php*-fpm) to actual unit name."""
|
||||
if "*" not in unit_pattern:
|
||||
return unit_pattern
|
||||
out, _ = exec_shell_sync("systemctl list-unit-files --type=service --no-legend 2>/dev/null | grep -E 'php[0-9.-]+-fpm' | head -1", timeout=5)
|
||||
if out:
|
||||
return out.split()[0]
|
||||
return "php8.2-fpm" # fallback
|
||||
|
||||
|
||||
def _service_status(unit: str) -> str:
|
||||
"""Get service status: active, inactive, failed, not-found."""
|
||||
resolved = _get_unit(unit)
|
||||
out, err = exec_shell_sync(f"systemctl is-active {resolved} 2>/dev/null", timeout=5)
|
||||
status = out.strip() if out else "inactive"
|
||||
if err or status not in ("active", "inactive", "failed", "activating"):
|
||||
return "inactive" if status else "not-found"
|
||||
return status
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def service_list(current_user: User = Depends(get_current_user)):
|
||||
"""List services with status"""
|
||||
result = []
|
||||
for s in SERVICES:
|
||||
unit = _get_unit(s["unit"])
|
||||
status = _service_status(s["unit"])
|
||||
result.append({
|
||||
**s,
|
||||
"unit": unit,
|
||||
"status": status,
|
||||
})
|
||||
return {"services": result}
|
||||
|
||||
|
||||
@router.post("/{service_id}/start")
|
||||
async def service_start(
|
||||
service_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Start service"""
|
||||
s = next((x for x in SERVICES if x["id"] == service_id), None)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
unit = _get_unit(s["unit"])
|
||||
out, err = exec_shell_sync(f"systemctl start {unit}", timeout=30)
|
||||
if err and "Failed" in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Started"}
|
||||
|
||||
|
||||
@router.post("/{service_id}/stop")
|
||||
async def service_stop(
|
||||
service_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Stop service"""
|
||||
s = next((x for x in SERVICES if x["id"] == service_id), None)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
unit = _get_unit(s["unit"])
|
||||
out, err = exec_shell_sync(f"systemctl stop {unit}", timeout=30)
|
||||
if err and "Failed" in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Stopped"}
|
||||
|
||||
|
||||
@router.post("/{service_id}/restart")
|
||||
async def service_restart(
|
||||
service_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Restart service"""
|
||||
s = next((x for x in SERVICES if x["id"] == service_id), None)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="Service not found")
|
||||
unit = _get_unit(s["unit"])
|
||||
out, err = exec_shell_sync(f"systemctl restart {unit}", timeout=30)
|
||||
if err and "Failed" in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Restarted"}
|
||||
364
YakPanel-server/backend/app/api/site.py
Normal file
364
YakPanel-server/backend/app/api/site.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""YakPanel - Site API"""
|
||||
import os
|
||||
import tarfile
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.notification import send_email
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.site import Site
|
||||
from app.models.redirect import SiteRedirect
|
||||
from app.services.site_service import create_site, list_sites, delete_site, get_site_with_domains, update_site, set_site_status, regenerate_site_vhost
|
||||
|
||||
router = APIRouter(prefix="/site", tags=["site"])
|
||||
|
||||
|
||||
class CreateSiteRequest(BaseModel):
|
||||
name: str
|
||||
path: str | None = None
|
||||
domains: list[str]
|
||||
project_type: str = "PHP"
|
||||
ps: str = ""
|
||||
php_version: str = "74"
|
||||
force_https: bool = False
|
||||
|
||||
|
||||
class UpdateSiteRequest(BaseModel):
|
||||
path: str | None = None
|
||||
domains: list[str] | None = None
|
||||
ps: str | None = None
|
||||
php_version: str | None = None
|
||||
force_https: bool | None = None
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def site_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all sites"""
|
||||
return await list_sites(db)
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def site_create(
|
||||
body: CreateSiteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new site"""
|
||||
cfg = get_runtime_config()
|
||||
path = body.path or os.path.join(cfg["www_root"], body.name)
|
||||
result = await create_site(
|
||||
db,
|
||||
name=body.name,
|
||||
path=path,
|
||||
domains=body.domains,
|
||||
project_type=body.project_type,
|
||||
ps=body.ps,
|
||||
php_version=body.php_version or "74",
|
||||
force_https=1 if body.force_https else 0,
|
||||
)
|
||||
if not result["status"]:
|
||||
raise HTTPException(status_code=400, detail=result["msg"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{site_id}")
|
||||
async def site_get(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get site with domains for editing"""
|
||||
data = await get_site_with_domains(db, site_id)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
return data
|
||||
|
||||
|
||||
@router.put("/{site_id}")
|
||||
async def site_update(
|
||||
site_id: int,
|
||||
body: UpdateSiteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update site domains, path, or note"""
|
||||
result = await update_site(
|
||||
db, site_id,
|
||||
path=body.path,
|
||||
domains=body.domains,
|
||||
ps=body.ps,
|
||||
php_version=body.php_version,
|
||||
force_https=None if body.force_https is None else (1 if body.force_https else 0),
|
||||
)
|
||||
if not result["status"]:
|
||||
raise HTTPException(status_code=400, detail=result["msg"])
|
||||
return result
|
||||
|
||||
|
||||
class SiteStatusRequest(BaseModel):
|
||||
status: int
|
||||
|
||||
|
||||
@router.post("/{site_id}/status")
|
||||
async def site_set_status(
|
||||
site_id: int,
|
||||
body: SiteStatusRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Enable (1) or disable (0) site"""
|
||||
if body.status not in (0, 1):
|
||||
raise HTTPException(status_code=400, detail="Status must be 0 or 1")
|
||||
result = await set_site_status(db, site_id, body.status)
|
||||
if not result["status"]:
|
||||
raise HTTPException(status_code=404, detail=result["msg"])
|
||||
return result
|
||||
|
||||
|
||||
class AddRedirectRequest(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
code: int = 301
|
||||
|
||||
|
||||
class GitCloneRequest(BaseModel):
|
||||
url: str
|
||||
branch: str = "main"
|
||||
|
||||
|
||||
@router.post("/{site_id}/git/clone")
|
||||
async def site_git_clone(
|
||||
site_id: int,
|
||||
body: GitCloneRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Clone Git repo into site path (git clone -b branch url .)"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
url = (body.url or "").strip()
|
||||
if not url or " " in url or ";" in url or "|" in url:
|
||||
raise HTTPException(status_code=400, detail="Invalid Git URL")
|
||||
path = site.path
|
||||
if not os.path.isdir(path):
|
||||
raise HTTPException(status_code=400, detail="Site path does not exist")
|
||||
branch = body.branch or "main"
|
||||
if os.path.isdir(os.path.join(path, ".git")):
|
||||
raise HTTPException(status_code=400, detail="Already a Git repo; use Pull instead")
|
||||
out, err = exec_shell_sync(
|
||||
f"cd {path} && git init && git remote add origin {url} && git fetch origin {branch} && git checkout -b {branch} origin/{branch}",
|
||||
timeout=120,
|
||||
)
|
||||
if err and "error" in err.lower() and "fatal" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Cloned"}
|
||||
|
||||
|
||||
@router.post("/{site_id}/git/pull")
|
||||
async def site_git_pull(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Git pull in site path"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
path = site.path
|
||||
if not os.path.isdir(os.path.join(path, ".git")):
|
||||
raise HTTPException(status_code=400, detail="Not a Git repository")
|
||||
out, err = exec_shell_sync(f"cd {path} && git pull", timeout=60)
|
||||
if err and "error" in err.lower() and "fatal" in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Pulled", "output": out}
|
||||
|
||||
|
||||
@router.get("/{site_id}/redirects")
|
||||
async def site_redirects_list(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List redirects for a site"""
|
||||
result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site_id).order_by(SiteRedirect.id))
|
||||
rows = result.scalars().all()
|
||||
return [{"id": r.id, "source": r.source, "target": r.target, "code": r.code} for r in rows]
|
||||
|
||||
|
||||
@router.post("/{site_id}/redirects")
|
||||
async def site_redirect_add(
|
||||
site_id: int,
|
||||
body: AddRedirectRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add redirect for a site"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if not body.source or not body.target:
|
||||
raise HTTPException(status_code=400, detail="Source and target required")
|
||||
if body.code not in (301, 302):
|
||||
raise HTTPException(status_code=400, detail="Code must be 301 or 302")
|
||||
r = SiteRedirect(site_id=site_id, source=body.source.strip(), target=body.target.strip(), code=body.code)
|
||||
db.add(r)
|
||||
await db.commit()
|
||||
regen = await regenerate_site_vhost(db, site_id)
|
||||
if not regen["status"]:
|
||||
pass # redirect saved, vhost may need manual reload
|
||||
return {"status": True, "msg": "Redirect added", "id": r.id}
|
||||
|
||||
|
||||
@router.delete("/{site_id}/redirects/{redirect_id}")
|
||||
async def site_redirect_delete(
|
||||
site_id: int,
|
||||
redirect_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a redirect"""
|
||||
result = await db.execute(select(SiteRedirect).where(SiteRedirect.id == redirect_id, SiteRedirect.site_id == site_id))
|
||||
r = result.scalar_one_or_none()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Redirect not found")
|
||||
await db.delete(r)
|
||||
await db.commit()
|
||||
await regenerate_site_vhost(db, site_id)
|
||||
return {"status": True, "msg": "Redirect deleted"}
|
||||
|
||||
|
||||
@router.delete("/{site_id}")
|
||||
async def site_delete(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a site"""
|
||||
result = await delete_site(db, site_id)
|
||||
if not result["status"]:
|
||||
raise HTTPException(status_code=404, detail=result["msg"])
|
||||
return result
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
filename: str
|
||||
|
||||
|
||||
@router.post("/{site_id}/backup")
|
||||
async def site_backup(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create tar.gz backup of site directory"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if not os.path.isdir(site.path):
|
||||
raise HTTPException(status_code=400, detail="Site path does not exist")
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = cfg["backup_path"]
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{site.name}_{ts}.tar.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
try:
|
||||
with tarfile.open(dest, "w:gz") as tf:
|
||||
tf.add(site.path, arcname=os.path.basename(site.path))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
# Send notification if email configured
|
||||
send_email(
|
||||
subject=f"YakPanel - Site backup: {site.name}",
|
||||
body=f"Backup completed: {filename}\nSite: {site.name}\nPath: {site.path}",
|
||||
)
|
||||
return {"status": True, "msg": "Backup created", "filename": filename}
|
||||
|
||||
|
||||
@router.get("/{site_id}/backups")
|
||||
async def site_backups_list(
|
||||
site_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List backups for a site"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
cfg = get_runtime_config()
|
||||
backup_dir = cfg["backup_path"]
|
||||
if not os.path.isdir(backup_dir):
|
||||
return {"backups": []}
|
||||
prefix = f"{site.name}_"
|
||||
backups = []
|
||||
for f in os.listdir(backup_dir):
|
||||
if f.startswith(prefix) and f.endswith(".tar.gz"):
|
||||
p = os.path.join(backup_dir, f)
|
||||
backups.append({"filename": f, "size": os.path.getsize(p) if os.path.isfile(p) else 0})
|
||||
backups.sort(key=lambda x: x["filename"], reverse=True)
|
||||
return {"backups": backups}
|
||||
|
||||
|
||||
@router.get("/{site_id}/backups/download")
|
||||
async def site_backup_download(
|
||||
site_id: int,
|
||||
file: str = Query(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download backup file"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{site.name}_") or not file.endswith(".tar.gz"):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
cfg = get_runtime_config()
|
||||
path = os.path.join(cfg["backup_path"], file)
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
return FileResponse(path, filename=file)
|
||||
|
||||
|
||||
@router.post("/{site_id}/restore")
|
||||
async def site_restore(
|
||||
site_id: int,
|
||||
body: RestoreRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Restore site from backup"""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
file = body.filename
|
||||
if ".." in file or "/" in file or "\\" in file or not file.startswith(f"{site.name}_") or not file.endswith(".tar.gz"):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
cfg = get_runtime_config()
|
||||
backup_path = os.path.join(cfg["backup_path"], file)
|
||||
if not os.path.isfile(backup_path):
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
parent = os.path.dirname(site.path)
|
||||
try:
|
||||
with tarfile.open(backup_path, "r:gz") as tf:
|
||||
tf.extractall(parent)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
return {"status": True, "msg": "Restored"}
|
||||
81
YakPanel-server/backend/app/api/soft.py
Normal file
81
YakPanel-server/backend/app/api/soft.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""YakPanel - App Store / Software API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/soft", tags=["soft"])
|
||||
|
||||
# Curated list of common server software (Debian/Ubuntu package names)
|
||||
SOFTWARE_LIST = [
|
||||
{"id": "nginx", "name": "Nginx", "desc": "Web server", "pkg": "nginx"},
|
||||
{"id": "mysql-server", "name": "MySQL Server", "desc": "Database server", "pkg": "mysql-server"},
|
||||
{"id": "mariadb-server", "name": "MariaDB", "desc": "Database server", "pkg": "mariadb-server"},
|
||||
{"id": "php", "name": "PHP", "desc": "PHP runtime", "pkg": "php"},
|
||||
{"id": "php-fpm", "name": "PHP-FPM", "desc": "PHP FastCGI", "pkg": "php-fpm"},
|
||||
{"id": "redis-server", "name": "Redis", "desc": "In-memory cache", "pkg": "redis-server"},
|
||||
{"id": "postgresql", "name": "PostgreSQL", "desc": "Database server", "pkg": "postgresql"},
|
||||
{"id": "mongodb", "name": "MongoDB", "desc": "NoSQL database", "pkg": "mongodb"},
|
||||
{"id": "certbot", "name": "Certbot", "desc": "Let's Encrypt SSL", "pkg": "certbot"},
|
||||
{"id": "docker", "name": "Docker", "desc": "Container runtime", "pkg": "docker.io"},
|
||||
{"id": "nodejs", "name": "Node.js", "desc": "JavaScript runtime", "pkg": "nodejs"},
|
||||
{"id": "npm", "name": "npm", "desc": "Node package manager", "pkg": "npm"},
|
||||
{"id": "git", "name": "Git", "desc": "Version control", "pkg": "git"},
|
||||
{"id": "python3", "name": "Python 3", "desc": "Python runtime", "pkg": "python3"},
|
||||
]
|
||||
|
||||
|
||||
def _check_installed(pkg: str) -> tuple[bool, str]:
|
||||
"""Check if package is installed. Returns (installed, version_or_error)."""
|
||||
out, err = exec_shell_sync(f"dpkg -l {pkg} 2>/dev/null | grep ^ii", timeout=5)
|
||||
if out.strip():
|
||||
# Parse version from dpkg output: ii pkg version ...
|
||||
parts = out.split()
|
||||
if len(parts) >= 3:
|
||||
return True, parts[2]
|
||||
return False, ""
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def soft_list(current_user: User = Depends(get_current_user)):
|
||||
"""List software with install status"""
|
||||
result = []
|
||||
for s in SOFTWARE_LIST:
|
||||
installed, version = _check_installed(s["pkg"])
|
||||
result.append({
|
||||
**s,
|
||||
"installed": installed,
|
||||
"version": version if installed else "",
|
||||
})
|
||||
return {"software": result}
|
||||
|
||||
|
||||
@router.post("/install/{pkg_id}")
|
||||
async def soft_install(
|
||||
pkg_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Install package via apt (requires root)"""
|
||||
pkg = next((s["pkg"] for s in SOFTWARE_LIST if s["id"] == pkg_id), None)
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
out, err = exec_shell_sync(f"apt-get update && apt-get install -y {pkg}", timeout=300)
|
||||
if err and "error" in err.lower() and "E: " in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Installed"}
|
||||
|
||||
|
||||
@router.post("/uninstall/{pkg_id}")
|
||||
async def soft_uninstall(
|
||||
pkg_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Uninstall package via apt"""
|
||||
pkg = next((s["pkg"] for s in SOFTWARE_LIST if s["id"] == pkg_id), None)
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
out, err = exec_shell_sync(f"apt-get remove -y {pkg}", timeout=120)
|
||||
if err and "error" in err.lower() and "E: " in err:
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Uninstalled"}
|
||||
85
YakPanel-server/backend/app/api/ssl.py
Normal file
85
YakPanel-server/backend/app/api/ssl.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""YakPanel - SSL/Domains API - Let's Encrypt via certbot"""
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import exec_shell_sync
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.site import Site, Domain
|
||||
|
||||
router = APIRouter(prefix="/ssl", tags=["ssl"])
|
||||
|
||||
|
||||
@router.get("/domains")
|
||||
async def ssl_domains(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all domains from sites with site path for certbot webroot"""
|
||||
result = await db.execute(
|
||||
select(Domain, Site).join(Site, Domain.pid == Site.id).order_by(Domain.name)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"port": d.port,
|
||||
"site_id": s.id,
|
||||
"site_name": s.name,
|
||||
"site_path": s.path,
|
||||
}
|
||||
for d, s in rows
|
||||
]
|
||||
|
||||
|
||||
class RequestCertRequest(BaseModel):
|
||||
domain: str
|
||||
webroot: str
|
||||
email: str
|
||||
|
||||
|
||||
@router.post("/request")
|
||||
async def ssl_request_cert(
|
||||
body: RequestCertRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Request Let's Encrypt certificate via certbot (webroot challenge)"""
|
||||
if not body.domain or not body.webroot or not body.email:
|
||||
raise HTTPException(status_code=400, detail="domain, webroot and email required")
|
||||
if ".." in body.domain or ".." in body.webroot:
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
cfg = get_runtime_config()
|
||||
allowed = [os.path.abspath(cfg["www_root"]), os.path.abspath(cfg["setup_path"])]
|
||||
webroot_abs = os.path.abspath(body.webroot)
|
||||
if not any(webroot_abs.startswith(a + os.sep) or webroot_abs == a for a in allowed):
|
||||
raise HTTPException(status_code=400, detail="Webroot must be under www_root or setup_path")
|
||||
cmd = (
|
||||
f'certbot certonly --webroot -w "{body.webroot}" -d "{body.domain}" '
|
||||
f'--non-interactive --agree-tos --email "{body.email}"'
|
||||
)
|
||||
out, err = exec_shell_sync(cmd, timeout=120)
|
||||
if err and "error" in err.lower() and "successfully" not in err.lower():
|
||||
raise HTTPException(status_code=500, detail=err.strip() or out.strip())
|
||||
return {"status": True, "msg": "Certificate requested", "output": out}
|
||||
|
||||
|
||||
@router.get("/certificates")
|
||||
async def ssl_list_certificates(current_user: User = Depends(get_current_user)):
|
||||
"""List existing Let's Encrypt certificates"""
|
||||
live_dir = "/etc/letsencrypt/live"
|
||||
if not os.path.isdir(live_dir):
|
||||
return {"certificates": []}
|
||||
certs = []
|
||||
for name in os.listdir(live_dir):
|
||||
if name.startswith("."):
|
||||
continue
|
||||
path = os.path.join(live_dir, name)
|
||||
if os.path.isdir(path) and os.path.isfile(os.path.join(path, "fullchain.pem")):
|
||||
certs.append({"name": name, "path": path})
|
||||
return {"certificates": sorted(certs, key=lambda x: x["name"])}
|
||||
68
YakPanel-server/backend/app/api/terminal.py
Normal file
68
YakPanel-server/backend/app/api/terminal.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""YakPanel - Web Terminal API (WebSocket)"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
router = APIRouter(prefix="/terminal", tags=["terminal"])
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def terminal_websocket(websocket: WebSocket):
|
||||
"""WebSocket terminal - spawns shell and streams I/O"""
|
||||
await websocket.accept()
|
||||
|
||||
token = websocket.query_params.get("token")
|
||||
if token:
|
||||
from app.core.security import decode_token
|
||||
if not decode_token(token):
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
if sys.platform == "win32":
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
"cmd.exe",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
"/bin/bash",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
env={**os.environ, "TERM": "xterm-256color"},
|
||||
)
|
||||
|
||||
async def read_stdout():
|
||||
try:
|
||||
while proc.returncode is None and proc.stdout:
|
||||
data = await proc.stdout.read(4096)
|
||||
if data:
|
||||
await websocket.send_text(data.decode("utf-8", errors="replace"))
|
||||
except (WebSocketDisconnect, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
async def read_websocket():
|
||||
try:
|
||||
while True:
|
||||
msg = await websocket.receive()
|
||||
data = msg.get("text") or (msg.get("bytes") or b"").decode("utf-8", errors="replace")
|
||||
if data and proc.stdin and not proc.stdin.is_closing():
|
||||
proc.stdin.write(data.encode("utf-8"))
|
||||
await proc.stdin.drain()
|
||||
except (WebSocketDisconnect, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
await asyncio.gather(read_stdout(), read_websocket())
|
||||
109
YakPanel-server/backend/app/api/user.py
Normal file
109
YakPanel-server/backend/app/api/user.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""YakPanel - User management API (admin only)"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_password_hash
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/user", tags=["user"])
|
||||
|
||||
|
||||
def require_superuser(current_user: User):
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: str = ""
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def user_list(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all users (admin only)"""
|
||||
require_superuser(current_user)
|
||||
result = await db.execute(select(User).order_by(User.id))
|
||||
rows = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"username": r.username,
|
||||
"email": r.email or "",
|
||||
"is_active": r.is_active,
|
||||
"is_superuser": r.is_superuser,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def user_create(
|
||||
body: CreateUserRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new user (admin only)"""
|
||||
require_superuser(current_user)
|
||||
if not body.username or len(body.username) < 2:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
|
||||
if not body.password or len(body.password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
result = await db.execute(select(User).where(User.username == body.username))
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
user = User(
|
||||
username=body.username,
|
||||
password=get_password_hash(body.password),
|
||||
email=body.email.strip() or None,
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "User created", "id": user.id}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def user_delete(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a user (admin only). Cannot delete self."""
|
||||
require_superuser(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "User deleted"}
|
||||
|
||||
|
||||
@router.put("/{user_id}/toggle-active")
|
||||
async def user_toggle_active(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Toggle user active status (admin only). Cannot deactivate self."""
|
||||
require_superuser(current_user)
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user.is_active = not user.is_active
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Updated", "is_active": user.is_active}
|
||||
1
YakPanel-server/backend/app/core/__init__.py
Normal file
1
YakPanel-server/backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# YakPanel - Core module
|
||||
88
YakPanel-server/backend/app/core/config.py
Normal file
88
YakPanel-server/backend/app/core/config.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""YakPanel - Configuration"""
|
||||
import os
|
||||
from typing import Any
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
# Runtime config loaded from DB on startup (overrides Settings)
|
||||
_runtime_config: dict[str, Any] = {}
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
app_name: str = "YakPanel"
|
||||
app_version: str = "1.0.0"
|
||||
debug: bool = False
|
||||
|
||||
# Paths (Ubuntu/Debian default)
|
||||
panel_path: str = "/www/server/YakPanel-server"
|
||||
setup_path: str = "/www/server"
|
||||
www_root: str = "/www/wwwroot"
|
||||
www_logs: str = "/www/wwwlogs"
|
||||
vhost_path: str = "/www/server/panel/vhost"
|
||||
|
||||
# Database (use absolute path for SQLite)
|
||||
database_url: str = "sqlite+aiosqlite:///./data/default.db"
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Auth
|
||||
secret_key: str = "YakPanel-server-secret-change-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 # 24 hours
|
||||
|
||||
# Panel
|
||||
panel_port: int = 8888
|
||||
webserver_type: str = "nginx" # nginx, apache, openlitespeed
|
||||
|
||||
# CORS (comma-separated origins, e.g. https://panel.example.com)
|
||||
cors_extra_origins: str = ""
|
||||
|
||||
# Remote SSH installer (disabled by default — high risk; see docs)
|
||||
enable_remote_installer: bool = False
|
||||
remote_install_default_url: str = "https://www.yakpanel.com/YakPanel-server/install.sh"
|
||||
remote_install_rate_limit_per_ip: int = 10
|
||||
remote_install_rate_window_minutes: int = 60
|
||||
# Comma-separated CIDRs; empty = no restriction (e.g. "10.0.0.0/8,192.168.0.0/16")
|
||||
remote_install_allowed_target_cidrs: str = ""
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def get_runtime_config() -> dict[str, Any]:
|
||||
"""Get effective panel config (Settings + DB overrides)."""
|
||||
s = get_settings()
|
||||
base = {
|
||||
"panel_port": s.panel_port,
|
||||
"www_root": s.www_root,
|
||||
"setup_path": s.setup_path,
|
||||
"www_logs": s.www_logs,
|
||||
"vhost_path": s.vhost_path,
|
||||
"webserver_type": s.webserver_type,
|
||||
"mysql_root": "",
|
||||
}
|
||||
for k, v in _runtime_config.items():
|
||||
if k in base:
|
||||
if k == "panel_port":
|
||||
try:
|
||||
base[k] = int(v)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
base[k] = v
|
||||
base["backup_path"] = os.path.join(base["setup_path"], "backup")
|
||||
return base
|
||||
|
||||
|
||||
def set_runtime_config_overrides(overrides: dict[str, str]) -> None:
|
||||
"""Set runtime config from DB (called on startup)."""
|
||||
global _runtime_config
|
||||
_runtime_config = dict(overrides)
|
||||
97
YakPanel-server/backend/app/core/database.py
Normal file
97
YakPanel-server/backend/app/core/database.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""YakPanel - Database configuration"""
|
||||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Ensure data directory exists for SQLite
|
||||
if "sqlite" in settings.database_url:
|
||||
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
data_dir = os.path.join(backend_dir, "data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""SQLAlchemy declarative base"""
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""Dependency for async database sessions"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
def _run_migrations(conn):
|
||||
"""Add new columns to existing tables (SQLite)."""
|
||||
import sqlalchemy
|
||||
try:
|
||||
r = conn.execute(sqlalchemy.text("PRAGMA table_info(sites)"))
|
||||
cols = [row[1] for row in r.fetchall()]
|
||||
if "php_version" not in cols:
|
||||
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN php_version VARCHAR(16) DEFAULT '74'"))
|
||||
if "force_https" not in cols:
|
||||
conn.execute(sqlalchemy.text("ALTER TABLE sites ADD COLUMN force_https INTEGER DEFAULT 0"))
|
||||
except Exception:
|
||||
pass
|
||||
# Create backup_plans if not exists (create_all handles new installs)
|
||||
try:
|
||||
conn.execute(sqlalchemy.text("""
|
||||
CREATE TABLE IF NOT EXISTS backup_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
plan_type VARCHAR(32) NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
schedule VARCHAR(64) NOT NULL,
|
||||
enabled BOOLEAN DEFAULT 1
|
||||
)
|
||||
"""))
|
||||
except Exception:
|
||||
pass
|
||||
# Create custom_plugins if not exists
|
||||
try:
|
||||
conn.execute(sqlalchemy.text("""
|
||||
CREATE TABLE IF NOT EXISTS custom_plugins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id VARCHAR(64) UNIQUE NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
version VARCHAR(32) DEFAULT '1.0',
|
||||
desc VARCHAR(512) DEFAULT '',
|
||||
source_url VARCHAR(512) DEFAULT '',
|
||||
enabled BOOLEAN DEFAULT 1
|
||||
)
|
||||
"""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables"""
|
||||
import app.models # noqa: F401 - register all models with Base.metadata
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
if "sqlite" in str(engine.url):
|
||||
await conn.run_sync(_run_migrations)
|
||||
41
YakPanel-server/backend/app/core/notification.py
Normal file
41
YakPanel-server/backend/app/core/notification.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""YakPanel - Email notifications"""
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from app.core.config import get_runtime_config
|
||||
|
||||
|
||||
def send_email(subject: str, body: str, to: str | None = None) -> tuple[bool, str]:
|
||||
"""Send email via SMTP. Returns (success, message)."""
|
||||
cfg = get_runtime_config()
|
||||
email_to = to or cfg.get("email_to", "").strip()
|
||||
smtp_server = cfg.get("smtp_server", "").strip()
|
||||
smtp_port = int(cfg.get("smtp_port") or 587)
|
||||
smtp_user = cfg.get("smtp_user", "").strip()
|
||||
smtp_password = cfg.get("smtp_password", "").strip()
|
||||
|
||||
if not email_to:
|
||||
return False, "Email recipient not configured"
|
||||
if not smtp_server:
|
||||
return False, "SMTP server not configured"
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_user or "YakPanel-server@localhost"
|
||||
msg["To"] = email_to
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
with smtplib.SMTP(smtp_server, smtp_port, timeout=15) as server:
|
||||
if smtp_user and smtp_password:
|
||||
server.starttls()
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.sendmail(msg["From"], [email_to], msg.as_string())
|
||||
return True, "Sent"
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
return False, f"SMTP auth failed: {e}"
|
||||
except smtplib.SMTPException as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
38
YakPanel-server/backend/app/core/security.py
Normal file
38
YakPanel-server/backend/app/core/security.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""YakPanel - Security utilities"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create JWT access token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and validate JWT token"""
|
||||
try:
|
||||
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
except JWTError:
|
||||
return None
|
||||
112
YakPanel-server/backend/app/core/utils.py
Normal file
112
YakPanel-server/backend/app/core/utils.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""YakPanel - Utility functions (ported from legacy panel public module)"""
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import asyncio
|
||||
import subprocess
|
||||
import html
|
||||
from typing import Tuple, Optional
|
||||
|
||||
regex_safe_path = re.compile(r"^[\w\s./\-]*$")
|
||||
|
||||
|
||||
def md5(strings: str | bytes) -> str:
|
||||
"""Generate MD5 hash"""
|
||||
if isinstance(strings, str):
|
||||
strings = strings.encode("utf-8")
|
||||
return hashlib.md5(strings).hexdigest()
|
||||
|
||||
|
||||
def read_file(filename: str, mode: str = "r") -> str | bytes | None:
|
||||
"""Read file contents"""
|
||||
if not os.path.exists(filename):
|
||||
return None
|
||||
try:
|
||||
with open(filename, mode, encoding="utf-8" if "b" not in mode else None) as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
try:
|
||||
with open(filename, mode) as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_file(filename: str, content: str | bytes, mode: str = "w+") -> bool:
|
||||
"""Write content to file"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(filename) or ".", exist_ok=True)
|
||||
with open(filename, mode, encoding="utf-8" if "b" not in mode else None) as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def xss_decode(text: str) -> str:
|
||||
"""Decode XSS-encoded text"""
|
||||
try:
|
||||
cs = {""": '"', """: '"', "'": "'", "'": "'"}
|
||||
for k, v in cs.items():
|
||||
text = text.replace(k, v)
|
||||
return html.unescape(text)
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def path_safe_check(path: str, force: bool = True) -> bool:
|
||||
"""Validate path for security (no traversal, no dangerous chars)"""
|
||||
if len(path) > 256:
|
||||
return False
|
||||
checks = ["..", "./", "\\", "%", "$", "^", "&", "*", "~", '"', "'", ";", "|", "{", "}", "`"]
|
||||
for c in checks:
|
||||
if c in path:
|
||||
return False
|
||||
if force and not regex_safe_path.match(path):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def exec_shell(
|
||||
cmd: str,
|
||||
timeout: Optional[float] = None,
|
||||
cwd: Optional[str] = None,
|
||||
) -> Tuple[str, str]:
|
||||
"""Execute shell command asynchronously. Returns (stdout, stderr)."""
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout or 300,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
return "", "Timed out"
|
||||
out = stdout.decode("utf-8", errors="replace") if stdout else ""
|
||||
err = stderr.decode("utf-8", errors="replace") if stderr else ""
|
||||
return out, err
|
||||
|
||||
|
||||
def exec_shell_sync(cmd: str, timeout: Optional[float] = None, cwd: Optional[str] = None) -> Tuple[str, str]:
|
||||
"""Execute shell command synchronously. Returns (stdout, stderr)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
timeout=timeout or 300,
|
||||
cwd=cwd,
|
||||
)
|
||||
out = result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
|
||||
err = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
|
||||
return out, err
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "Timed out"
|
||||
except Exception as e:
|
||||
return "", str(e)
|
||||
99
YakPanel-server/backend/app/main.py
Normal file
99
YakPanel-server/backend/app/main.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""YakPanel - Main FastAPI application"""
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings, set_runtime_config_overrides
|
||||
from app.core.database import init_db, AsyncSessionLocal
|
||||
from app.models.config import Config
|
||||
from app.api import (
|
||||
auth,
|
||||
backup,
|
||||
dashboard,
|
||||
user,
|
||||
site,
|
||||
ftp,
|
||||
database,
|
||||
files,
|
||||
crontab,
|
||||
firewall,
|
||||
ssl,
|
||||
monitor,
|
||||
docker,
|
||||
plugin,
|
||||
soft,
|
||||
terminal,
|
||||
config,
|
||||
logs,
|
||||
node,
|
||||
service,
|
||||
public_installer,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan - init DB on startup, load config from DB"""
|
||||
await init_db()
|
||||
# Load panel config from DB (persisted settings from Settings page)
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Config))
|
||||
rows = result.scalars().all()
|
||||
overrides = {r.key: r.value for r in rows if r.value is not None}
|
||||
set_runtime_config_overrides(overrides)
|
||||
yield
|
||||
# Cleanup if needed
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
_cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||
_extra = (settings.cors_extra_origins or "").strip()
|
||||
if _extra:
|
||||
_cors_origins.extend([o.strip() for o in _extra.split(",") if o.strip()])
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(backup.router, prefix="/api/v1")
|
||||
app.include_router(dashboard.router, prefix="/api/v1")
|
||||
app.include_router(site.router, prefix="/api/v1")
|
||||
app.include_router(ftp.router, prefix="/api/v1")
|
||||
app.include_router(database.router, prefix="/api/v1")
|
||||
app.include_router(files.router, prefix="/api/v1")
|
||||
app.include_router(crontab.router, prefix="/api/v1")
|
||||
app.include_router(firewall.router, prefix="/api/v1")
|
||||
app.include_router(ssl.router, prefix="/api/v1")
|
||||
app.include_router(monitor.router, prefix="/api/v1")
|
||||
app.include_router(docker.router, prefix="/api/v1")
|
||||
app.include_router(plugin.router, prefix="/api/v1")
|
||||
app.include_router(soft.router, prefix="/api/v1")
|
||||
app.include_router(node.router, prefix="/api/v1")
|
||||
app.include_router(service.router, prefix="/api/v1")
|
||||
app.include_router(terminal.router, prefix="/api/v1")
|
||||
app.include_router(config.router, prefix="/api/v1")
|
||||
app.include_router(user.router, prefix="/api/v1")
|
||||
app.include_router(logs.router, prefix="/api/v1")
|
||||
app.include_router(public_installer.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"app": settings.app_name, "version": settings.app_version}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
13
YakPanel-server/backend/app/models/__init__.py
Normal file
13
YakPanel-server/backend/app/models/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# YakPanel - Models
|
||||
from app.models.user import User
|
||||
from app.models.config import Config
|
||||
from app.models.site import Site, Domain
|
||||
from app.models.redirect import SiteRedirect
|
||||
from app.models.ftp import Ftp
|
||||
from app.models.database import Database
|
||||
from app.models.crontab import Crontab
|
||||
from app.models.firewall import FirewallRule
|
||||
from app.models.backup_plan import BackupPlan
|
||||
from app.models.plugin import CustomPlugin
|
||||
|
||||
__all__ = ["User", "Config", "Site", "Domain", "SiteRedirect", "Ftp", "Database", "Crontab", "FirewallRule", "BackupPlan", "CustomPlugin"]
|
||||
15
YakPanel-server/backend/app/models/backup_plan.py
Normal file
15
YakPanel-server/backend/app/models/backup_plan.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""YakPanel - Backup plan model for scheduled backups"""
|
||||
from sqlalchemy import String, Integer, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BackupPlan(Base):
|
||||
__tablename__ = "backup_plans"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
plan_type: Mapped[str] = mapped_column(String(32), nullable=False) # site | database
|
||||
target_id: Mapped[int] = mapped_column(Integer, nullable=False) # site_id or database_id
|
||||
schedule: Mapped[str] = mapped_column(String(64), nullable=False) # cron expression, e.g. "0 2 * * *" = daily 2am
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
12
YakPanel-server/backend/app/models/config.py
Normal file
12
YakPanel-server/backend/app/models/config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""YakPanel - Config model (panel settings)"""
|
||||
from sqlalchemy import String, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Config(Base):
|
||||
__tablename__ = "config"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=True, default="")
|
||||
16
YakPanel-server/backend/app/models/crontab.py
Normal file
16
YakPanel-server/backend/app/models/crontab.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""YakPanel - Crontab model"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Crontab(Base):
|
||||
__tablename__ = "crontab"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), default="")
|
||||
type: Mapped[str] = mapped_column(String(32), default="shell")
|
||||
execstr: Mapped[str] = mapped_column(Text, default="")
|
||||
schedule: Mapped[str] = mapped_column(String(64), default="")
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
19
YakPanel-server/backend/app/models/database.py
Normal file
19
YakPanel-server/backend/app/models/database.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""YakPanel - Database model"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Database(Base):
|
||||
__tablename__ = "databases"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
sid: Mapped[int] = mapped_column(Integer, default=0)
|
||||
pid: Mapped[int] = mapped_column(Integer, ForeignKey("sites.id"), default=0)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(255), default="")
|
||||
db_type: Mapped[str] = mapped_column(String(32), default="MySQL")
|
||||
ps: Mapped[str] = mapped_column(String(255), default="")
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
14
YakPanel-server/backend/app/models/firewall.py
Normal file
14
YakPanel-server/backend/app/models/firewall.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""YakPanel - Firewall model"""
|
||||
from sqlalchemy import String, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class FirewallRule(Base):
|
||||
__tablename__ = "firewall"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
port: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
protocol: Mapped[str] = mapped_column(String(16), default="tcp")
|
||||
action: Mapped[str] = mapped_column(String(16), default="accept")
|
||||
ps: Mapped[str] = mapped_column(String(255), default="")
|
||||
17
YakPanel-server/backend/app/models/ftp.py
Normal file
17
YakPanel-server/backend/app/models/ftp.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""YakPanel - FTP model"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Ftp(Base):
|
||||
__tablename__ = "ftps"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
pid: Mapped[int] = mapped_column(Integer, ForeignKey("sites.id"), default=0)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
ps: Mapped[str] = mapped_column(String(255), default="")
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
16
YakPanel-server/backend/app/models/plugin.py
Normal file
16
YakPanel-server/backend/app/models/plugin.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""YakPanel - Custom plugin model (third-party plugins added from URL)"""
|
||||
from sqlalchemy import String, Integer, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class CustomPlugin(Base):
|
||||
__tablename__ = "custom_plugins"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
plugin_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) # unique id from manifest
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
version: Mapped[str] = mapped_column(String(32), default="1.0")
|
||||
desc: Mapped[str] = mapped_column(String(512), default="")
|
||||
source_url: Mapped[str] = mapped_column(String(512), default="") # URL it was installed from
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
14
YakPanel-server/backend/app/models/redirect.py
Normal file
14
YakPanel-server/backend/app/models/redirect.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""YakPanel - Site redirect model"""
|
||||
from sqlalchemy import String, Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SiteRedirect(Base):
|
||||
__tablename__ = "site_redirects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
site_id: Mapped[int] = mapped_column(Integer, ForeignKey("sites.id"), nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(512), nullable=False) # e.g. /old-path or domain.com/old
|
||||
target: Mapped[str] = mapped_column(String(512), nullable=False) # e.g. /new-path or https://...
|
||||
code: Mapped[int] = mapped_column(Integer, default=301) # 301 or 302
|
||||
29
YakPanel-server/backend/app/models/site.py
Normal file
29
YakPanel-server/backend/app/models/site.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""YakPanel - Site and Domain models"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Site(Base):
|
||||
__tablename__ = "sites"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
status: Mapped[int] = mapped_column(Integer, default=1) # 0=stopped, 1=running
|
||||
ps: Mapped[str] = mapped_column(String(255), default="")
|
||||
project_type: Mapped[str] = mapped_column(String(32), default="PHP")
|
||||
php_version: Mapped[str] = mapped_column(String(16), default="74") # 74, 80, 81, 82
|
||||
force_https: Mapped[int] = mapped_column(Integer, default=0) # 0=off, 1=redirect HTTP to HTTPS
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Domain(Base):
|
||||
__tablename__ = "domain"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
pid: Mapped[int] = mapped_column(Integer, ForeignKey("sites.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
port: Mapped[str] = mapped_column(String(16), default="80")
|
||||
addtime: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
18
YakPanel-server/backend/app/models/user.py
Normal file
18
YakPanel-server/backend/app/models/user.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""YakPanel - User model"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, DateTime, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(128), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
1
YakPanel-server/backend/app/services/__init__.py
Normal file
1
YakPanel-server/backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# YakPanel - Services
|
||||
49
YakPanel-server/backend/app/services/config_service.py
Normal file
49
YakPanel-server/backend/app/services/config_service.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""YakPanel - Config service (panel settings)"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.config import Config
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
async def get_config_value(db: AsyncSession, key: str) -> str:
|
||||
"""Get config value by key"""
|
||||
result = await db.execute(select(Config).where(Config.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
return row.value if row else ""
|
||||
|
||||
|
||||
async def set_config_value(db: AsyncSession, key: str, value: str) -> None:
|
||||
"""Set config value"""
|
||||
result = await db.execute(select(Config).where(Config.key == key))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
db.add(Config(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
|
||||
def get_webserver_type() -> str:
|
||||
"""Get webserver type (nginx, apache, openlitespeed)"""
|
||||
return get_settings().webserver_type
|
||||
|
||||
|
||||
def get_setup_path() -> str:
|
||||
"""Get server setup path"""
|
||||
return get_settings().setup_path
|
||||
|
||||
|
||||
def get_www_root() -> str:
|
||||
"""Get www root path"""
|
||||
return get_settings().www_root
|
||||
|
||||
|
||||
def get_www_logs() -> str:
|
||||
"""Get www logs path"""
|
||||
return get_settings().www_logs
|
||||
|
||||
|
||||
def get_vhost_path() -> str:
|
||||
"""Get vhost config path"""
|
||||
return get_settings().vhost_path
|
||||
411
YakPanel-server/backend/app/services/database_service.py
Normal file
411
YakPanel-server/backend/app/services/database_service.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""YakPanel - Database service (MySQL creation via CLI; PostgreSQL/MongoDB/Redis panel records only)"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from app.core.config import get_runtime_config
|
||||
|
||||
|
||||
def get_mysql_root() -> str | None:
|
||||
"""Get MySQL root password from config (key: mysql_root)"""
|
||||
cfg = get_runtime_config()
|
||||
return cfg.get("mysql_root") or None
|
||||
|
||||
|
||||
def _run_mysql(sql: str, root_pw: str) -> tuple[str, str]:
|
||||
"""Run mysql with SQL. Uses MYSQL_PWD to avoid password in argv."""
|
||||
env = os.environ.copy()
|
||||
env["MYSQL_PWD"] = root_pw
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mysql", "-u", "root", "-e", sql],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env=env,
|
||||
)
|
||||
return r.stdout or "", r.stderr or ""
|
||||
except FileNotFoundError:
|
||||
return "", "mysql command not found"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "Timed out"
|
||||
|
||||
|
||||
def create_mysql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create MySQL database and user via mysql CLI. Returns (success, message)."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured. Set it in Settings."
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters in name: {x}"
|
||||
pw_esc = password.replace("\\", "\\\\").replace("'", "''")
|
||||
steps = [
|
||||
(f"CREATE DATABASE IF NOT EXISTS `{db_name}`;", "create database"),
|
||||
(f"CREATE USER IF NOT EXISTS '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';", "create user"),
|
||||
(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{username}'@'localhost';", "grant"),
|
||||
("FLUSH PRIVILEGES;", "flush"),
|
||||
]
|
||||
for sql, step in steps:
|
||||
out, err = _run_mysql(sql, root_pw)
|
||||
if err and "error" in err.lower() and "already exists" not in err.lower():
|
||||
return False, f"{step}: {err.strip() or out.strip()}"
|
||||
return True, "Database created"
|
||||
|
||||
|
||||
def drop_mysql_database(db_name: str, username: str) -> tuple[bool, str]:
|
||||
"""Drop MySQL database and user."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured"
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters: {x}"
|
||||
steps = [
|
||||
(f"DROP DATABASE IF EXISTS `{db_name}`;", "drop database"),
|
||||
(f"DROP USER IF EXISTS '{username}'@'localhost';", "drop user"),
|
||||
("FLUSH PRIVILEGES;", "flush"),
|
||||
]
|
||||
for sql, step in steps:
|
||||
out, err = _run_mysql(sql, root_pw)
|
||||
if err and "error" in err.lower():
|
||||
return False, f"{step}: {err.strip() or out.strip()}"
|
||||
return True, "Database dropped"
|
||||
|
||||
|
||||
def backup_mysql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
|
||||
"""Create mysqldump backup. Returns (success, message, filename)."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured", None
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name", None
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{db_name}_{ts}.sql.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
env = os.environ.copy()
|
||||
env["MYSQL_PWD"] = root_pw
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'mysqldump -u root {db_name} | gzip > "{dest}"',
|
||||
shell=True,
|
||||
env=env,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0 or not os.path.isfile(dest):
|
||||
return False, r.stderr or r.stdout or "Backup failed", None
|
||||
return True, "Backup created", filename
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out", None
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def restore_mysql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
|
||||
"""Restore MySQL database from .sql.gz backup."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured"
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name"
|
||||
if not os.path.isfile(backup_path):
|
||||
return False, "Backup file not found"
|
||||
env = os.environ.copy()
|
||||
env["MYSQL_PWD"] = root_pw
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'gunzip -c "{backup_path}" | mysql -u root {db_name}',
|
||||
shell=True,
|
||||
env=env,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, r.stderr or r.stdout or "Restore failed"
|
||||
return True, "Restored"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def change_mysql_password(username: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change MySQL user password."""
|
||||
root_pw = get_mysql_root()
|
||||
if not root_pw:
|
||||
return False, "MySQL root password not configured"
|
||||
if not all(c.isalnum() or c == "_" for c in username):
|
||||
return False, "Invalid username"
|
||||
pw_esc = new_password.replace("\\", "\\\\").replace("'", "''")
|
||||
sql = f"ALTER USER '{username}'@'localhost' IDENTIFIED BY '{pw_esc}';"
|
||||
out, err = _run_mysql(sql, root_pw)
|
||||
if err and "error" in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
return True, "Password updated"
|
||||
|
||||
|
||||
def create_postgresql_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create PostgreSQL database and user via psql (runs as postgres). Returns (success, message)."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters in name: {x}"
|
||||
pw_esc = password.replace("'", "''")
|
||||
cmds = [
|
||||
f"CREATE DATABASE {db_name};",
|
||||
f"CREATE USER {username} WITH PASSWORD '{pw_esc}';",
|
||||
f"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {username};",
|
||||
f"GRANT ALL ON SCHEMA public TO {username};",
|
||||
]
|
||||
for sql in cmds:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", sql],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
if "already exists" in err.lower():
|
||||
continue
|
||||
return False, err
|
||||
except FileNotFoundError:
|
||||
return False, "PostgreSQL (psql) not found"
|
||||
return True, "Database created"
|
||||
|
||||
|
||||
def drop_postgresql_database(db_name: str, username: str) -> tuple[bool, str]:
|
||||
"""Drop PostgreSQL database and user."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters: {x}"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP DATABASE IF EXISTS {db_name};"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", f"DROP USER IF EXISTS {username};"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Database dropped"
|
||||
except FileNotFoundError:
|
||||
return False, "PostgreSQL (psql) not found"
|
||||
|
||||
|
||||
def change_postgresql_password(username: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change PostgreSQL user password."""
|
||||
if not all(c.isalnum() or c == "_" for c in username):
|
||||
return False, "Invalid username"
|
||||
pw_esc = new_password.replace("'", "''")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-u", "postgres", "psql", "-tAc", f"ALTER USER {username} WITH PASSWORD '{pw_esc}';"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Password updated"
|
||||
except FileNotFoundError:
|
||||
return False, "PostgreSQL (psql) not found"
|
||||
|
||||
|
||||
def create_mongodb_database(db_name: str, username: str, password: str) -> tuple[bool, str]:
|
||||
"""Create MongoDB database and user via mongosh."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters in name: {x}"
|
||||
pw_esc = password.replace("\\", "\\\\").replace("'", "\\'")
|
||||
js = (
|
||||
f"db = db.getSiblingDB('{db_name}'); "
|
||||
f"db.createUser({{user: '{username}', pwd: '{pw_esc}', roles: [{{role: 'readWrite', db: '{db_name}'}}]}});"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mongosh", "--quiet", "--eval", js],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
if "already exists" in err.lower():
|
||||
return True, "Database created"
|
||||
return False, err
|
||||
return True, "Database created"
|
||||
except FileNotFoundError:
|
||||
return False, "MongoDB (mongosh) not found"
|
||||
|
||||
|
||||
def drop_mongodb_database(db_name: str, username: str) -> tuple[bool, str]:
|
||||
"""Drop MongoDB database and user."""
|
||||
for x in (db_name, username):
|
||||
if not all(c.isalnum() or c == "_" for c in x):
|
||||
return False, f"Invalid characters: {x}"
|
||||
try:
|
||||
js = f"db = db.getSiblingDB('{db_name}'); db.dropUser('{username}'); db.dropDatabase();"
|
||||
r = subprocess.run(
|
||||
["mongosh", "--quiet", "--eval", js],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Database dropped"
|
||||
except FileNotFoundError:
|
||||
return False, "MongoDB (mongosh) not found"
|
||||
|
||||
|
||||
def backup_postgresql_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
|
||||
"""Create PostgreSQL backup via pg_dump. Returns (success, message, filename)."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name", None
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{db_name}_{ts}.sql.gz"
|
||||
dest = os.path.join(backup_dir, filename)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'sudo -u postgres pg_dump {db_name} | gzip > "{dest}"',
|
||||
shell=True,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0 or not os.path.isfile(dest):
|
||||
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
|
||||
return True, "Backup created", filename
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out", None
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def restore_postgresql_database(db_name: str, backup_path: str) -> tuple[bool, str]:
|
||||
"""Restore PostgreSQL database from .sql.gz backup."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name"
|
||||
if not os.path.isfile(backup_path):
|
||||
return False, "Backup file not found"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f'gunzip -c "{backup_path}" | sudo -u postgres psql -d {db_name}',
|
||||
shell=True,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Restore failed").strip()[:200]
|
||||
return True, "Restored"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def backup_mongodb_database(db_name: str, backup_dir: str) -> tuple[bool, str, str | None]:
|
||||
"""Create MongoDB backup via mongodump. Returns (success, message, filename)."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name", None
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = os.path.join(backup_dir, f"{db_name}_{ts}")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mongodump", "--db", db_name, "--out", out_dir, "--gzip"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Backup failed").strip()[:200], None
|
||||
# Create tarball of the dump
|
||||
archive = out_dir + ".tar.gz"
|
||||
r2 = subprocess.run(
|
||||
f'tar -czf "{archive}" -C "{backup_dir}" "{os.path.basename(out_dir)}"',
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if os.path.isdir(out_dir):
|
||||
shutil.rmtree(out_dir, ignore_errors=True)
|
||||
if r2.returncode != 0 or not os.path.isfile(archive):
|
||||
return False, "Archive failed", None
|
||||
return True, "Backup created", os.path.basename(archive)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Timed out", None
|
||||
except Exception as e:
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
def restore_mongodb_database(db_name: str, backup_path: str) -> tuple[bool, str]:
|
||||
"""Restore MongoDB database from .tar.gz mongodump backup."""
|
||||
if not all(c.isalnum() or c == "_" for c in db_name):
|
||||
return False, "Invalid database name"
|
||||
if not os.path.isfile(backup_path):
|
||||
return False, "Backup file not found"
|
||||
import tempfile
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["tar", "-xzf", backup_path, "-C", tmp],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Extract failed").strip()[:200]
|
||||
# Find the extracted dir (mongodump creates db_name/ subdir)
|
||||
extracted = os.path.join(tmp, os.listdir(tmp)[0]) if os.listdir(tmp) else None
|
||||
if not extracted or not os.path.isdir(extracted):
|
||||
return False, "Invalid backup format"
|
||||
r2 = subprocess.run(
|
||||
["mongorestore", "--db", db_name, "--gzip", "--drop", extracted],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
if r2.returncode != 0:
|
||||
return False, (r2.stderr or r2.stdout or "Restore failed").strip()[:200]
|
||||
return True, "Restored"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def change_mongodb_password(username: str, db_name: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change MongoDB user password."""
|
||||
if not all(c.isalnum() or c == "_" for c in username):
|
||||
return False, "Invalid username"
|
||||
pw_esc = new_password.replace("\\", "\\\\").replace("'", "\\'")
|
||||
js = f"db = db.getSiblingDB('{db_name}'); db.changeUserPassword('{username}', '{pw_esc}');"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mongosh", "--quiet", "--eval", js],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return False, (r.stderr or r.stdout or "Failed").strip()[:200]
|
||||
return True, "Password updated"
|
||||
except FileNotFoundError:
|
||||
return False, "MongoDB (mongosh) not found"
|
||||
69
YakPanel-server/backend/app/services/ftp_service.py
Normal file
69
YakPanel-server/backend/app/services/ftp_service.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""YakPanel - FTP service (Pure-FTPd via pure-pw)"""
|
||||
import os
|
||||
import subprocess
|
||||
from app.core.config import get_runtime_config
|
||||
|
||||
|
||||
def _run_pure_pw(args: str, stdin: str | None = None) -> tuple[str, str]:
|
||||
"""Run pure-pw command. Returns (stdout, stderr)."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
f"pure-pw {args}",
|
||||
shell=True,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return r.stdout or "", r.stderr or ""
|
||||
except FileNotFoundError:
|
||||
return "", "pure-pw not found"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "Timed out"
|
||||
|
||||
|
||||
def create_ftp_user(name: str, password: str, path: str) -> tuple[bool, str]:
|
||||
"""Create Pure-FTPd virtual user via pure-pw."""
|
||||
if not all(c.isalnum() or c in "._-" for c in name):
|
||||
return False, "Invalid username"
|
||||
if ".." in path:
|
||||
return False, "Invalid path"
|
||||
path_abs = os.path.abspath(path)
|
||||
cfg = get_runtime_config()
|
||||
www_root = os.path.abspath(cfg["www_root"])
|
||||
if not (path_abs == www_root or path_abs.startswith(www_root + os.sep)):
|
||||
return False, "Path must be under www_root"
|
||||
os.makedirs(path_abs, exist_ok=True)
|
||||
# pure-pw useradd prompts for password twice; pipe it
|
||||
stdin = f"{password}\n{password}\n"
|
||||
out, err = _run_pure_pw(
|
||||
f'useradd {name} -u www-data -d "{path_abs}" -m',
|
||||
stdin=stdin,
|
||||
)
|
||||
if err and "error" in err.lower() and "already exists" not in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
out2, err2 = _run_pure_pw("mkdb")
|
||||
if err2 and "error" in err2.lower():
|
||||
return False, err2.strip() or out2.strip()
|
||||
return True, "FTP user created"
|
||||
|
||||
|
||||
def delete_ftp_user(name: str) -> tuple[bool, str]:
|
||||
"""Delete Pure-FTPd virtual user."""
|
||||
if not all(c.isalnum() or c in "._-" for c in name):
|
||||
return False, "Invalid username"
|
||||
out, err = _run_pure_pw(f'userdel {name} -m')
|
||||
if err and "error" in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
return True, "FTP user deleted"
|
||||
|
||||
|
||||
def update_ftp_password(name: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change Pure-FTPd user password."""
|
||||
if not all(c.isalnum() or c in "._-" for c in name):
|
||||
return False, "Invalid username"
|
||||
stdin = f"{new_password}\n{new_password}\n"
|
||||
out, err = _run_pure_pw(f'passwd {name} -m', stdin=stdin)
|
||||
if err and "error" in err.lower():
|
||||
return False, err.strip() or out.strip()
|
||||
return True, "Password updated"
|
||||
338
YakPanel-server/backend/app/services/site_service.py
Normal file
338
YakPanel-server/backend/app/services/site_service.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""YakPanel - Site service"""
|
||||
import os
|
||||
import re
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.site import Site, Domain
|
||||
from app.models.redirect import SiteRedirect
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import path_safe_check, write_file, read_file, exec_shell_sync
|
||||
|
||||
|
||||
DOMAIN_REGEX = re.compile(r"^([\w\-\*]{1,100}\.){1,8}([\w\-]{1,24}|[\w\-]{1,24}\.[\w\-]{1,24})$")
|
||||
|
||||
|
||||
def _render_vhost(
|
||||
template: str,
|
||||
server_names: str,
|
||||
root_path: str,
|
||||
logs_path: str,
|
||||
site_name: str,
|
||||
php_version: str,
|
||||
force_https: int,
|
||||
redirects: list[tuple[str, str, int]] | None = None,
|
||||
) -> str:
|
||||
"""Render nginx vhost template. redirects: [(source, target, code), ...]"""
|
||||
force_block = "return 301 https://$host$request_uri;" if force_https else ""
|
||||
redirect_lines = []
|
||||
for src, tgt, code in (redirects or []):
|
||||
if src and tgt:
|
||||
redirect_lines.append(f" location = {src} {{ return {code} {tgt}; }}")
|
||||
redirect_block = "\n".join(redirect_lines) if redirect_lines else ""
|
||||
content = template.replace("{SERVER_NAMES}", server_names)
|
||||
content = content.replace("{ROOT_PATH}", root_path)
|
||||
content = content.replace("{LOGS_PATH}", logs_path)
|
||||
content = content.replace("{SITE_NAME}", site_name)
|
||||
content = content.replace("{PHP_VERSION}", php_version or "74")
|
||||
content = content.replace("{FORCE_HTTPS_BLOCK}", force_block)
|
||||
content = content.replace("{REDIRECTS_BLOCK}", redirect_block)
|
||||
return content
|
||||
|
||||
|
||||
async def domain_format(domains: list[str]) -> str | None:
|
||||
"""Validate domain format. Returns first invalid domain or None."""
|
||||
for d in domains:
|
||||
if not DOMAIN_REGEX.match(d):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
async def domain_exists(db: AsyncSession, domains: list[str], exclude_site_id: int | None = None) -> str | None:
|
||||
"""Check if domain already exists. Returns first existing domain or None."""
|
||||
for d in domains:
|
||||
parts = d.split(":")
|
||||
name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
||||
q = select(Domain).where(Domain.name == name, Domain.port == port)
|
||||
if exclude_site_id is not None:
|
||||
q = q.where(Domain.pid != exclude_site_id)
|
||||
result = await db.execute(q)
|
||||
if result.scalar_one_or_none():
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
async def create_site(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
path: str,
|
||||
domains: list[str],
|
||||
project_type: str = "PHP",
|
||||
ps: str = "",
|
||||
php_version: str = "74",
|
||||
force_https: int = 0,
|
||||
) -> dict:
|
||||
"""Create a new site with vhost config."""
|
||||
if not path_safe_check(name) or not path_safe_check(path):
|
||||
return {"status": False, "msg": "Invalid site name or path"}
|
||||
|
||||
invalid = await domain_format(domains)
|
||||
if invalid:
|
||||
return {"status": False, "msg": f"Invalid domain format: {invalid}"}
|
||||
|
||||
existing = await domain_exists(db, domains)
|
||||
if existing:
|
||||
return {"status": False, "msg": f"Domain already exists: {existing}"}
|
||||
|
||||
cfg = get_runtime_config()
|
||||
setup_path = cfg["setup_path"]
|
||||
www_root = cfg["www_root"]
|
||||
www_logs = cfg["www_logs"]
|
||||
vhost_path = os.path.join(setup_path, "panel", "vhost", "nginx")
|
||||
|
||||
site_path = os.path.join(www_root, name)
|
||||
if not os.path.exists(site_path):
|
||||
os.makedirs(site_path, 0o755)
|
||||
|
||||
site = Site(name=name, path=site_path, ps=ps, project_type=project_type, php_version=php_version or "74", force_https=force_https or 0)
|
||||
db.add(site)
|
||||
await db.flush()
|
||||
|
||||
for d in domains:
|
||||
parts = d.split(":")
|
||||
domain_name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
||||
db.add(Domain(pid=site.id, name=domain_name, port=port))
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Generate Nginx vhost
|
||||
conf_path = os.path.join(vhost_path, f"{name}.conf")
|
||||
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
||||
|
||||
if os.path.exists(template_path):
|
||||
template = read_file(template_path) or ""
|
||||
server_names = " ".join(d.split(":")[0] for d in domains)
|
||||
content = _render_vhost(template, server_names, site_path, www_logs, name, php_version or "74", force_https or 0, [])
|
||||
write_file(conf_path, content)
|
||||
|
||||
# Reload Nginx if available
|
||||
nginx_bin = os.path.join(setup_path, "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Site created", "id": site.id}
|
||||
|
||||
|
||||
async def list_sites(db: AsyncSession) -> list[dict]:
|
||||
"""List all sites with domain count."""
|
||||
result = await db.execute(select(Site).order_by(Site.id))
|
||||
sites = result.scalars().all()
|
||||
out = []
|
||||
for s in sites:
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == s.id))
|
||||
domains = domain_result.scalars().all()
|
||||
out.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"path": s.path,
|
||||
"status": s.status,
|
||||
"ps": s.ps,
|
||||
"project_type": s.project_type,
|
||||
"domain_count": len(domains),
|
||||
"addtime": s.addtime.isoformat() if s.addtime else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
async def delete_site(db: AsyncSession, site_id: int) -> dict:
|
||||
"""Delete a site and its vhost config."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
|
||||
await db.execute(Domain.__table__.delete().where(Domain.pid == site_id))
|
||||
await db.execute(SiteRedirect.__table__.delete().where(SiteRedirect.site_id == site_id))
|
||||
await db.delete(site)
|
||||
|
||||
cfg = get_runtime_config()
|
||||
conf_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx", f"{site.name}.conf")
|
||||
if os.path.exists(conf_path):
|
||||
os.remove(conf_path)
|
||||
|
||||
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -s reload")
|
||||
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Site deleted"}
|
||||
|
||||
|
||||
async def get_site_count(db: AsyncSession) -> int:
|
||||
"""Get total site count."""
|
||||
from sqlalchemy import func
|
||||
result = await db.execute(select(func.count()).select_from(Site))
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_site_with_domains(db: AsyncSession, site_id: int) -> dict | None:
|
||||
"""Get site with domain list for editing."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return None
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
||||
domains = domain_result.scalars().all()
|
||||
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domains]
|
||||
return {
|
||||
"id": site.id,
|
||||
"name": site.name,
|
||||
"path": site.path,
|
||||
"status": site.status,
|
||||
"ps": site.ps,
|
||||
"project_type": site.project_type,
|
||||
"php_version": getattr(site, "php_version", None) or "74",
|
||||
"force_https": getattr(site, "force_https", 0) or 0,
|
||||
"domains": domain_list,
|
||||
}
|
||||
|
||||
|
||||
async def update_site(
|
||||
db: AsyncSession,
|
||||
site_id: int,
|
||||
path: str | None = None,
|
||||
domains: list[str] | None = None,
|
||||
ps: str | None = None,
|
||||
php_version: str | None = None,
|
||||
force_https: int | None = None,
|
||||
) -> dict:
|
||||
"""Update site domains, path, or note."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
|
||||
if domains is not None:
|
||||
invalid = await domain_format(domains)
|
||||
if invalid:
|
||||
return {"status": False, "msg": f"Invalid domain format: {invalid}"}
|
||||
existing = await domain_exists(db, domains, exclude_site_id=site_id)
|
||||
if existing:
|
||||
return {"status": False, "msg": f"Domain already exists: {existing}"}
|
||||
await db.execute(Domain.__table__.delete().where(Domain.pid == site_id))
|
||||
for d in domains:
|
||||
parts = d.split(":")
|
||||
domain_name, port = parts[0], parts[1] if len(parts) > 1 else "80"
|
||||
db.add(Domain(pid=site.id, name=domain_name, port=port))
|
||||
|
||||
if path is not None and path_safe_check(path):
|
||||
site.path = path
|
||||
|
||||
if ps is not None:
|
||||
site.ps = ps
|
||||
if php_version is not None:
|
||||
site.php_version = php_version or "74"
|
||||
if force_https is not None:
|
||||
site.force_https = 1 if force_https else 0
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Regenerate Nginx vhost if domains, php_version, or force_https changed
|
||||
if domains is not None or php_version is not None or force_https is not None:
|
||||
cfg = get_runtime_config()
|
||||
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
||||
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
||||
if os.path.exists(template_path):
|
||||
template = read_file(template_path) or ""
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
||||
domain_rows = domain_result.scalars().all()
|
||||
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domain_rows]
|
||||
server_names = " ".join(d.split(":")[0] for d in domain_list) if domain_list else site.name
|
||||
php_ver = getattr(site, "php_version", None) or "74"
|
||||
fhttps = getattr(site, "force_https", 0) or 0
|
||||
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
||||
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
||||
content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects)
|
||||
write_file(conf_path, content)
|
||||
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
|
||||
await db.commit()
|
||||
return {"status": True, "msg": "Site updated"}
|
||||
|
||||
|
||||
def _vhost_path(site_name: str) -> tuple[str, str]:
|
||||
"""Return (conf_path, disabled_path) for site vhost."""
|
||||
cfg = get_runtime_config()
|
||||
vhost_dir = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||
disabled_dir = os.path.join(vhost_dir, "disabled")
|
||||
return (
|
||||
os.path.join(vhost_dir, f"{site_name}.conf"),
|
||||
os.path.join(disabled_dir, f"{site_name}.conf"),
|
||||
)
|
||||
|
||||
|
||||
async def set_site_status(db: AsyncSession, site_id: int, status: int) -> dict:
|
||||
"""Enable (1) or disable (0) site by moving vhost config."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
|
||||
conf_path, disabled_path = _vhost_path(site.name)
|
||||
disabled_dir = os.path.dirname(disabled_path)
|
||||
|
||||
if status == 1: # enable
|
||||
if os.path.isfile(disabled_path):
|
||||
os.makedirs(os.path.dirname(conf_path), exist_ok=True)
|
||||
os.rename(disabled_path, conf_path)
|
||||
else: # disable
|
||||
if os.path.isfile(conf_path):
|
||||
os.makedirs(disabled_dir, exist_ok=True)
|
||||
os.rename(conf_path, disabled_path)
|
||||
|
||||
site.status = status
|
||||
await db.commit()
|
||||
|
||||
nginx_bin = os.path.join(get_runtime_config()["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
|
||||
return {"status": True, "msg": "Site " + ("enabled" if status == 1 else "disabled")}
|
||||
|
||||
|
||||
async def regenerate_site_vhost(db: AsyncSession, site_id: int) -> dict:
|
||||
"""Regenerate nginx vhost for a site (e.g. after redirect changes)."""
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
cfg = get_runtime_config()
|
||||
vhost_path = os.path.join(cfg["setup_path"], "panel", "vhost", "nginx")
|
||||
conf_path = os.path.join(vhost_path, f"{site.name}.conf")
|
||||
if site.status != 1:
|
||||
return {"status": True, "msg": "Site disabled, vhost not active"}
|
||||
panel_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
template_path = os.path.join(panel_root, "webserver", "templates", "nginx_site.conf")
|
||||
if not os.path.exists(template_path):
|
||||
return {"status": False, "msg": "Template not found"}
|
||||
template = read_file(template_path) or ""
|
||||
domain_result = await db.execute(select(Domain).where(Domain.pid == site.id))
|
||||
domain_rows = domain_result.scalars().all()
|
||||
domain_list = [f"{d.name}:{d.port}" if d.port != "80" else d.name for d in domain_rows]
|
||||
server_names = " ".join(d.split(":")[0] for d in domain_list) if domain_list else site.name
|
||||
php_ver = getattr(site, "php_version", None) or "74"
|
||||
fhttps = getattr(site, "force_https", 0) or 0
|
||||
redir_result = await db.execute(select(SiteRedirect).where(SiteRedirect.site_id == site.id))
|
||||
redirects = [(r.source, r.target, r.code or 301) for r in redir_result.scalars().all()]
|
||||
content = _render_vhost(template, server_names, site.path, cfg["www_logs"], site.name, php_ver, fhttps, redirects)
|
||||
write_file(conf_path, content)
|
||||
nginx_bin = os.path.join(cfg["setup_path"], "nginx", "sbin", "nginx")
|
||||
if os.path.exists(nginx_bin):
|
||||
exec_shell_sync(f"{nginx_bin} -t && {nginx_bin} -s reload")
|
||||
return {"status": True, "msg": "Vhost regenerated"}
|
||||
4
YakPanel-server/backend/app/tasks/__init__.py
Normal file
4
YakPanel-server/backend/app/tasks/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# YakPanel - Celery tasks
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
20
YakPanel-server/backend/app/tasks/celery_app.py
Normal file
20
YakPanel-server/backend/app/tasks/celery_app.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""YakPanel - Celery application"""
|
||||
from celery import Celery
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
celery_app = Celery(
|
||||
"cit_panel",
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
include=["app.tasks.install"],
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
)
|
||||
42
YakPanel-server/backend/app/tasks/install.py
Normal file
42
YakPanel-server/backend/app/tasks/install.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""YakPanel - Install tasks (one-click install via apt)"""
|
||||
import subprocess
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
# Map panel package IDs to apt package names (same as soft.py)
|
||||
PKG_MAP = {
|
||||
"nginx": "nginx",
|
||||
"mysql-server": "mysql-server",
|
||||
"mariadb-server": "mariadb-server",
|
||||
"php": "php",
|
||||
"php-fpm": "php-fpm",
|
||||
"redis-server": "redis-server",
|
||||
"postgresql": "postgresql",
|
||||
"mongodb": "mongodb",
|
||||
"certbot": "certbot",
|
||||
"docker": "docker.io",
|
||||
"nodejs": "nodejs",
|
||||
"npm": "npm",
|
||||
"git": "git",
|
||||
"python3": "python3",
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def install_software(name: str, version: str = ""):
|
||||
"""Install software via apt (Debian/Ubuntu). name = package id from soft list."""
|
||||
pkg = PKG_MAP.get(name, name)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f"apt-get update && apt-get install -y {pkg}",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
err = (result.stderr or result.stdout or b"").decode("utf-8", errors="replace")
|
||||
return {"status": "failed", "name": name, "error": err.strip()[:500]}
|
||||
return {"status": "ok", "name": name, "version": version}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "failed", "name": name, "error": "Installation timed out"}
|
||||
except Exception as e:
|
||||
return {"status": "failed", "name": name, "error": str(e)}
|
||||
13
YakPanel-server/backend/pyproject.toml
Normal file
13
YakPanel-server/backend/pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "yakpanel-server"
|
||||
version = "1.0.0"
|
||||
description = "YakPanel - Web hosting control panel"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
28
YakPanel-server/backend/requirements.txt
Normal file
28
YakPanel-server/backend/requirements.txt
Normal file
@@ -0,0 +1,28 @@
|
||||
# YakPanel - Backend Dependencies
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy>=2.0.25
|
||||
alembic>=1.13.0
|
||||
aiosqlite>=0.19.0
|
||||
asyncpg>=0.29.0
|
||||
|
||||
# Auth
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Redis & Celery
|
||||
redis>=5.0.0
|
||||
celery>=5.3.0
|
||||
|
||||
# Utils
|
||||
psutil>=5.9.0
|
||||
croniter>=2.0.0
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Remote SSH installer (optional)
|
||||
asyncssh>=2.14.0
|
||||
5
YakPanel-server/backend/run.py
Normal file
5
YakPanel-server/backend/run.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Run YakPanel backend"""
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8888, reload=True)
|
||||
35
YakPanel-server/backend/scripts/seed_admin.py
Normal file
35
YakPanel-server/backend/scripts/seed_admin.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Seed admin user for YakPanel"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Run from backend directory
|
||||
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
os.chdir(backend_dir)
|
||||
sys.path.insert(0, backend_dir)
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.core.database import AsyncSessionLocal, init_db
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
async def seed():
|
||||
await init_db()
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(User).where(User.username == "admin"))
|
||||
if result.scalar_one_or_none():
|
||||
print("Admin user already exists")
|
||||
return
|
||||
admin = User(
|
||||
username="admin",
|
||||
password=get_password_hash("admin"),
|
||||
is_superuser=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
print("Admin user created: username=admin, password=admin")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
43
YakPanel-server/docker-compose.yml
Normal file
43
YakPanel-server/docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8888
|
||||
ports:
|
||||
- "8888:8888"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- YakPanel-server-data:/app/data
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- DATABASE_URL=sqlite+aiosqlite:///./data/default.db
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
celery:
|
||||
build: ./backend
|
||||
command: celery -A app.tasks.celery_app worker -l info
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
YakPanel-server-data:
|
||||
11
YakPanel-server/frontend/Dockerfile
Normal file
11
YakPanel-server/frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
13
YakPanel-server/frontend/index.html
Normal file
13
YakPanel-server/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YakPanel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
YakPanel-server/frontend/nginx.conf
Normal file
16
YakPanel-server/frontend/nginx.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location /api {
|
||||
proxy_pass http://backend:8888;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
31
YakPanel-server/frontend/package.json
Normal file
31
YakPanel-server/frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "yakpanel-server-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
YakPanel-server/frontend/postcss.config.js
Normal file
6
YakPanel-server/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
YakPanel-server/frontend/public/favicon.svg
Normal file
4
YakPanel-server/frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="12" fill="#2563eb"/>
|
||||
<text x="50" y="68" font-size="36" font-weight="bold" fill="white" text-anchor="middle" font-family="sans-serif">YP</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
74
YakPanel-server/frontend/src/App.tsx
Normal file
74
YakPanel-server/frontend/src/App.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Layout } from './components/Layout'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { SitePage } from './pages/SitePage'
|
||||
import { FilesPage } from './pages/FilesPage'
|
||||
import { FtpPage } from './pages/FtpPage'
|
||||
import { DatabasePage } from './pages/DatabasePage'
|
||||
import { TerminalPage } from './pages/TerminalPage'
|
||||
import { MonitorPage } from './pages/MonitorPage'
|
||||
import { CrontabPage } from './pages/CrontabPage'
|
||||
import { ConfigPage } from './pages/ConfigPage'
|
||||
import { LogsPage } from './pages/LogsPage'
|
||||
import { FirewallPage } from './pages/FirewallPage'
|
||||
import { DomainsPage } from './pages/DomainsPage'
|
||||
import { DockerPage } from './pages/DockerPage'
|
||||
import { NodePage } from './pages/NodePage'
|
||||
import { SoftPage } from './pages/SoftPage'
|
||||
import { ServicesPage } from './pages/ServicesPage'
|
||||
import { PluginsPage } from './pages/PluginsPage'
|
||||
import { BackupPlansPage } from './pages/BackupPlansPage'
|
||||
import { UsersPage } from './pages/UsersPage'
|
||||
import { RemoteInstallPage } from './pages/RemoteInstallPage'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/install" element={<RemoteInstallPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="site" element={<SitePage />} />
|
||||
<Route path="ftp" element={<FtpPage />} />
|
||||
<Route path="database" element={<DatabasePage />} />
|
||||
<Route path="docker" element={<DockerPage />} />
|
||||
<Route path="control" element={<MonitorPage />} />
|
||||
<Route path="firewall" element={<FirewallPage />} />
|
||||
<Route path="files" element={<FilesPage />} />
|
||||
<Route path="node" element={<NodePage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
<Route path="ssl_domain" element={<DomainsPage />} />
|
||||
<Route path="xterm" element={<TerminalPage />} />
|
||||
<Route path="crontab" element={<CrontabPage />} />
|
||||
<Route path="soft" element={<SoftPage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="services" element={<ServicesPage />} />
|
||||
<Route path="plugins" element={<PluginsPage />} />
|
||||
<Route path="backup-plans" element={<BackupPlansPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
</Route>
|
||||
<Route path="/logout" element={<LogoutRedirect />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function LogoutRedirect() {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
440
YakPanel-server/frontend/src/api/client.ts
Normal file
440
YakPanel-server/frontend/src/api/client.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = localStorage.getItem('token')
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || err.message || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const form = new FormData()
|
||||
form.append('username', username)
|
||||
form.append('password', password)
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || `Login failed`)
|
||||
}
|
||||
const data = await res.json()
|
||||
localStorage.setItem('token', data.access_token)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createSite(data: { name: string; domains: string[]; path?: string; ps?: string; php_version?: string; force_https?: boolean }) {
|
||||
return apiRequest<{ status: boolean; msg: string; id?: number }>('/site/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSite(siteId: number) {
|
||||
return apiRequest<{ id: number; name: string; path: string; status: number; ps: string; project_type: string; php_version: string; force_https: number; domains: string[] }>(
|
||||
`/site/${siteId}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateSite(siteId: number, data: { path?: string; domains?: string[]; ps?: string }) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listSiteRedirects(siteId: number) {
|
||||
return apiRequest<{ id: number; source: string; target: string; code: number }[]>(`/site/${siteId}/redirects`)
|
||||
}
|
||||
|
||||
export async function addSiteRedirect(siteId: number, source: string, target: string, code = 301) {
|
||||
return apiRequest<{ status: boolean; msg: string; id: number }>(`/site/${siteId}/redirects`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source, target, code }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function siteGitClone(siteId: number, url: string, branch = 'main') {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}/git/clone`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url, branch }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function siteGitPull(siteId: number) {
|
||||
return apiRequest<{ status: boolean; msg: string; output?: string }>(`/site/${siteId}/git/pull`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteSiteRedirect(siteId: number, redirectId: number) {
|
||||
return apiRequest<{ status: boolean }>(`/site/${siteId}/redirects/${redirectId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function setSiteStatus(siteId: number, status: boolean) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status: status ? 1 : 0 }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function applyFirewallRules() {
|
||||
return apiRequest<{ status: boolean; msg: string; count: number }>('/firewall/apply', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function deleteSite(siteId: number) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function createSiteBackup(siteId: number) {
|
||||
return apiRequest<{ status: boolean; msg: string; filename: string }>(`/site/${siteId}/backup`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function listSiteBackups(siteId: number) {
|
||||
return apiRequest<{ backups: { filename: string; size: number }[] }>(`/site/${siteId}/backups`)
|
||||
}
|
||||
|
||||
export async function restoreSiteBackup(siteId: number, filename: string) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}/restore`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadSiteBackup(siteId: number, filename: string): Promise<void> {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`/api/v1/site/${siteId}/backups/download?file=${encodeURIComponent(filename)}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function listFiles(path: string) {
|
||||
return apiRequest<{ path: string; items: { name: string; is_dir: boolean; size: number }[] }>(
|
||||
`/files/list?path=${encodeURIComponent(path)}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function uploadFile(path: string, file: File) {
|
||||
const form = new FormData()
|
||||
form.append('path', path)
|
||||
form.append('file', file)
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch('/api/v1/files/upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || 'Upload failed')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function readFile(path: string) {
|
||||
return apiRequest<{ path: string; content: string }>(`/files/read?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
|
||||
export async function writeFile(path: string, content: string) {
|
||||
return apiRequest<{ status: boolean }>('/files/write', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, content }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function mkdirFile(path: string, name: string) {
|
||||
return apiRequest<{ status: boolean; msg: string }>('/files/mkdir', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, name }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function renameFile(path: string, oldName: string, newName: string) {
|
||||
return apiRequest<{ status: boolean; msg: string }>('/files/rename', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, old_name: oldName, new_name: newName }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteFile(path: string, name: string, isDir: boolean) {
|
||||
return apiRequest<{ status: boolean; msg: string }>('/files/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path, name, is_dir: isDir }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadFile(path: string): Promise<void> {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`/api/v1/files/download?path=${encodeURIComponent(path)}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = path.split('/').pop() || 'download'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function listLogs(path: string) {
|
||||
return apiRequest<{ path: string; items: { name: string; is_dir: boolean; size: number }[] }>(
|
||||
`/logs/list?path=${encodeURIComponent(path)}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function readLog(path: string, tail = 1000) {
|
||||
return apiRequest<{ path: string; content: string; total_lines: number }>(
|
||||
`/logs/read?path=${encodeURIComponent(path)}&tail=${tail}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function listSslDomains() {
|
||||
return apiRequest<{ id: number; name: string; port: string; site_id: number; site_name: string; site_path: string }[]>(
|
||||
'/ssl/domains'
|
||||
)
|
||||
}
|
||||
|
||||
export async function requestSslCert(domain: string, webroot: string, email: string) {
|
||||
return apiRequest<{ status: boolean }>('/ssl/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ domain, webroot, email }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listSslCertificates() {
|
||||
return apiRequest<{ certificates: { name: string; path: string }[] }>('/ssl/certificates')
|
||||
}
|
||||
|
||||
export async function nodeAddProcess(script: string, name?: string) {
|
||||
return apiRequest<{ status: boolean; msg: string }>('/node/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ script, name: name || '' }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listDockerContainers() {
|
||||
return apiRequest<{ containers: { id: string; id_full: string; image: string; status: string; names: string; ports: string }[]; error?: string }>(
|
||||
'/docker/containers'
|
||||
)
|
||||
}
|
||||
|
||||
export async function listDockerImages() {
|
||||
return apiRequest<{ images: { repository: string; tag: string; id: string; size: string }[]; error?: string }>(
|
||||
'/docker/images'
|
||||
)
|
||||
}
|
||||
|
||||
export async function dockerPull(image: string) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/docker/pull?image=${encodeURIComponent(image)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function dockerRun(image: string, name?: string, ports?: string, cmd?: string) {
|
||||
return apiRequest<{ status: boolean; msg: string; id?: string }>('/docker/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ image, name: name || '', ports: ports || '', cmd: cmd || '' }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function dockerStart(containerId: string) {
|
||||
return apiRequest<{ status: boolean }>(`/docker/${containerId}/start`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function dockerStop(containerId: string) {
|
||||
return apiRequest<{ status: boolean }>(`/docker/${containerId}/stop`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function dockerRestart(containerId: string) {
|
||||
return apiRequest<{ status: boolean }>(`/docker/${containerId}/restart`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function createDatabaseBackup(dbId: number) {
|
||||
return apiRequest<{ status: boolean; msg: string; filename: string }>(`/database/${dbId}/backup`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function listDatabaseBackups(dbId: number) {
|
||||
return apiRequest<{ backups: { filename: string; size: number }[] }>(`/database/${dbId}/backups`)
|
||||
}
|
||||
|
||||
export async function restoreDatabaseBackup(dbId: number, filename: string) {
|
||||
return apiRequest<{ status: boolean }>(`/database/${dbId}/restore`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadDatabaseBackup(dbId: number, filename: string): Promise<void> {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(
|
||||
`/api/v1/database/${dbId}/backups/download?file=${encodeURIComponent(filename)}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} }
|
||||
)
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function testEmail() {
|
||||
return apiRequest<{ status: boolean; msg: string }>('/config/test-email', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function listUsers() {
|
||||
return apiRequest<{ id: number; username: string; email: string; is_active: boolean; is_superuser: boolean }[]>('/user/list')
|
||||
}
|
||||
|
||||
export async function createUser(data: { username: string; password: string; email?: string }) {
|
||||
return apiRequest<{ status: boolean; msg: string; id: number }>('/user/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: number) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/user/${userId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function toggleUserActive(userId: number) {
|
||||
return apiRequest<{ status: boolean; msg: string; is_active: boolean }>(`/user/${userId}/toggle-active`, { method: 'PUT' })
|
||||
}
|
||||
|
||||
export async function changePassword(oldPassword: string, newPassword: string) {
|
||||
return apiRequest<{ message: string }>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listServices() {
|
||||
return apiRequest<{ services: { id: string; name: string; unit: string; status: string }[] }>('/service/list')
|
||||
}
|
||||
|
||||
export async function serviceStart(id: string) {
|
||||
return apiRequest<{ status: boolean }>(`/service/${id}/start`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function serviceStop(id: string) {
|
||||
return apiRequest<{ status: boolean }>(`/service/${id}/stop`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function serviceRestart(id: string) {
|
||||
return apiRequest<{ status: boolean }>(`/service/${id}/restart`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function updateFtpPassword(ftpId: number, password: string) {
|
||||
return apiRequest<{ status: boolean }>(`/ftp/${ftpId}/password`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateDatabasePassword(dbId: number, password: string) {
|
||||
return apiRequest<{ status: boolean }>(`/database/${dbId}/password`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getDashboardStats() {
|
||||
return apiRequest<{
|
||||
site_count: number
|
||||
ftp_count: number
|
||||
database_count: number
|
||||
system: {
|
||||
cpu_percent: number
|
||||
memory_percent: number
|
||||
memory_used_mb: number
|
||||
memory_total_mb: number
|
||||
disk_percent: number
|
||||
disk_used_gb: number
|
||||
disk_total_gb: number
|
||||
}
|
||||
}>('/dashboard/stats')
|
||||
}
|
||||
|
||||
export async function applyCrontab() {
|
||||
return apiRequest<{ status: boolean; msg: string; count: number }>('/crontab/apply', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function getMonitorProcesses(limit = 50) {
|
||||
return apiRequest<{ processes: { pid: number; name: string; username: string; cpu_percent: number; memory_percent: number; status: string }[] }>(
|
||||
`/monitor/processes?limit=${limit}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function addPluginFromUrl(url: string) {
|
||||
return apiRequest<{ status: boolean; msg: string; id: string }>('/plugin/add-from-url', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePlugin(pluginId: string) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/plugin/${encodeURIComponent(pluginId)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function getMonitorNetwork() {
|
||||
return apiRequest<{ bytes_sent: number; bytes_recv: number; bytes_sent_mb: number; bytes_recv_mb: number }>('/monitor/network')
|
||||
}
|
||||
|
||||
export async function listBackupPlans() {
|
||||
return apiRequest<{ id: number; name: string; plan_type: string; target_id: number; schedule: string; enabled: boolean }[]>('/backup/plans')
|
||||
}
|
||||
|
||||
export async function createBackupPlan(data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
|
||||
return apiRequest<{ status: boolean; msg: string; id: number }>('/backup/plans', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateBackupPlan(planId: number, data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteBackupPlan(planId: number) {
|
||||
return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function runScheduledBackups() {
|
||||
return apiRequest<{ status: boolean; results: { plan: string; status: string; msg?: string }[] }>('/backup/run-scheduled', { method: 'POST' })
|
||||
}
|
||||
56
YakPanel-server/frontend/src/components/Layout.tsx
Normal file
56
YakPanel-server/frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { Home, LogOut, Archive, Users } from 'lucide-react'
|
||||
import { menuItems } from '../config/menu'
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
menuHome: <Home className="w-5 h-5" />,
|
||||
menuBackupPlans: <Archive className="w-5 h-5" />,
|
||||
menuUsers: <Users className="w-5 h-5" />,
|
||||
menuLogout: <LogOut className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<aside className="w-56 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-white">YakPanel</h1>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto p-2">
|
||||
{menuItems
|
||||
.filter((m) => m.id !== 'menuLogout')
|
||||
.map((item) => (
|
||||
<NavLink
|
||||
key={item.id}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{iconMap[item.id] || <span className="w-5 h-5" />}
|
||||
<span>{item.title}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => navigate('/logout')}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span>Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
YakPanel-server/frontend/src/config/menu.ts
Normal file
29
YakPanel-server/frontend/src/config/menu.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface MenuItem {
|
||||
title: string
|
||||
href: string
|
||||
id: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export const menuItems: MenuItem[] = [
|
||||
{ title: "Home", href: "/", id: "menuHome", sort: 1 },
|
||||
{ title: "Website", href: "/site", id: "menuSite", sort: 2 },
|
||||
{ title: "FTP", href: "/ftp", id: "menuFtp", sort: 3 },
|
||||
{ title: "Databases", href: "/database", id: "menuDatabase", sort: 4 },
|
||||
{ title: "Docker", href: "/docker", id: "menuDocker", sort: 5 },
|
||||
{ title: "Monitor", href: "/control", id: "menuControl", sort: 6 },
|
||||
{ title: "Security", href: "/firewall", id: "menuFirewall", sort: 7 },
|
||||
{ title: "Files", href: "/files", id: "menuFiles", sort: 8 },
|
||||
{ title: "Node", href: "/node", id: "menuNode", sort: 9 },
|
||||
{ title: "Logs", href: "/logs", id: "menuLogs", sort: 10 },
|
||||
{ title: "Domains", href: "/ssl_domain", id: "menuDomains", sort: 11 },
|
||||
{ title: "Terminal", href: "/xterm", id: "menuXterm", sort: 12 },
|
||||
{ title: "Cron", href: "/crontab", id: "menuCrontab", sort: 13 },
|
||||
{ title: "App Store", href: "/soft", id: "menuSoft", sort: 14 },
|
||||
{ title: "Services", href: "/services", id: "menuServices", sort: 15 },
|
||||
{ title: "Plugins", href: "/plugins", id: "menuPlugins", sort: 16 },
|
||||
{ title: "Backup Plans", href: "/backup-plans", id: "menuBackupPlans", sort: 17 },
|
||||
{ title: "Users", href: "/users", id: "menuUsers", sort: 18 },
|
||||
{ title: "Settings", href: "/config", id: "menuConfig", sort: 19 },
|
||||
{ title: "Log out", href: "/logout", id: "menuLogout", sort: 20 },
|
||||
]
|
||||
9
YakPanel-server/frontend/src/index.css
Normal file
9
YakPanel-server/frontend/src/index.css
Normal file
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
13
YakPanel-server/frontend/src/main.tsx
Normal file
13
YakPanel-server/frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
395
YakPanel-server/frontend/src/pages/BackupPlansPage.tsx
Normal file
395
YakPanel-server/frontend/src/pages/BackupPlansPage.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
apiRequest,
|
||||
listBackupPlans,
|
||||
createBackupPlan,
|
||||
updateBackupPlan,
|
||||
deleteBackupPlan,
|
||||
runScheduledBackups,
|
||||
} from '../api/client'
|
||||
import { Plus, Trash2, Play, Pencil } from 'lucide-react'
|
||||
|
||||
interface BackupPlanRecord {
|
||||
id: number
|
||||
name: string
|
||||
plan_type: string
|
||||
target_id: number
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SiteRecord {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DbRecord {
|
||||
id: number
|
||||
name: string
|
||||
db_type: string
|
||||
}
|
||||
|
||||
export function BackupPlansPage() {
|
||||
const [plans, setPlans] = useState<BackupPlanRecord[]>([])
|
||||
const [sites, setSites] = useState<SiteRecord[]>([])
|
||||
const [databases, setDatabases] = useState<DbRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editPlan, setEditPlan] = useState<BackupPlanRecord | null>(null)
|
||||
const [editPlanType, setEditPlanType] = useState<'site' | 'database'>('site')
|
||||
const [runLoading, setRunLoading] = useState(false)
|
||||
const [runResults, setRunResults] = useState<{ plan: string; status: string; msg?: string }[] | null>(null)
|
||||
const [createPlanType, setCreatePlanType] = useState<'site' | 'database'>('site')
|
||||
|
||||
const loadPlans = () => {
|
||||
listBackupPlans()
|
||||
.then(setPlans)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
listBackupPlans(),
|
||||
apiRequest<SiteRecord[]>('/site/list'),
|
||||
apiRequest<DbRecord[]>('/database/list'),
|
||||
])
|
||||
.then(([p, s, d]) => {
|
||||
setPlans(p)
|
||||
setSites(s)
|
||||
setDatabases(d.filter((x) => ['MySQL', 'PostgreSQL', 'MongoDB'].includes(x.db_type)))
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
|
||||
const plan_type = (form.elements.namedItem('plan_type') as HTMLSelectElement).value as 'site' | 'database'
|
||||
const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value)
|
||||
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
|
||||
const enabled = (form.elements.namedItem('enabled') as HTMLInputElement).checked
|
||||
|
||||
if (!name || !schedule || !target_id) {
|
||||
setError('Name, target and schedule are required')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
createBackupPlan({ name, plan_type, target_id, schedule, enabled })
|
||||
.then(() => {
|
||||
setShowCreate(false)
|
||||
form.reset()
|
||||
loadPlans()
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setCreating(false))
|
||||
}
|
||||
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (!confirm(`Delete backup plan "${name}"?`)) return
|
||||
deleteBackupPlan(id)
|
||||
.then(loadPlans)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleEdit = (plan: BackupPlanRecord) => {
|
||||
setEditPlan(plan)
|
||||
setEditPlanType(plan.plan_type as 'site' | 'database')
|
||||
}
|
||||
|
||||
const handleUpdate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!editPlan) return
|
||||
const form = e.currentTarget
|
||||
const name = (form.elements.namedItem('edit_name') as HTMLInputElement).value.trim()
|
||||
const plan_type = editPlanType
|
||||
const target_id = Number((form.elements.namedItem('edit_target_id') as HTMLSelectElement).value)
|
||||
const schedule = (form.elements.namedItem('edit_schedule') as HTMLInputElement).value.trim()
|
||||
const enabled = (form.elements.namedItem('edit_enabled') as HTMLInputElement).checked
|
||||
|
||||
if (!name || !schedule || !target_id) return
|
||||
updateBackupPlan(editPlan.id, { name, plan_type: editPlanType, target_id, schedule, enabled })
|
||||
.then(() => {
|
||||
setEditPlan(null)
|
||||
loadPlans()
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleRunScheduled = () => {
|
||||
setRunLoading(true)
|
||||
setRunResults(null)
|
||||
runScheduledBackups()
|
||||
.then((r) => setRunResults(r.results))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setRunLoading(false))
|
||||
}
|
||||
|
||||
const getTargetName = (plan: BackupPlanRecord) => {
|
||||
if (plan.plan_type === 'site') {
|
||||
const s = sites.find((x) => x.id === plan.target_id)
|
||||
return s ? s.name : `#${plan.target_id}`
|
||||
}
|
||||
const d = databases.find((x) => x.id === plan.target_id)
|
||||
return d ? d.name : `#${plan.target_id}`
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Backup Plans</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRunScheduled}
|
||||
disabled={runLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
{runLoading ? 'Running...' : 'Run Scheduled'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runResults && runResults.length > 0 && (
|
||||
<div className="mb-4 p-4 rounded bg-gray-100 dark:bg-gray-700">
|
||||
<h3 className="font-medium mb-2">Last run results</h3>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{runResults.map((r, i) => (
|
||||
<li key={i}>
|
||||
{r.plan}: {r.status === 'ok' ? '✓' : r.status === 'skipped' ? '⊘' : '✗'} {r.msg || ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Schedule automated backups. Add a cron entry (e.g. <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">0 * * * *</code> hourly) to call{' '}
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">POST /api/v1/backup/run-scheduled</code> with your auth token.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Target</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Schedule</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{plans.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
No backup plans. Click "Add Plan" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
plans.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white">{p.name}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{p.plan_type}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{getTargetName(p)}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400 font-mono text-sm">{p.schedule}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{p.enabled ? 'Yes' : 'No'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => handleEdit(p)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editPlan && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Edit Backup Plan</h2>
|
||||
<form onSubmit={handleUpdate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||
<input
|
||||
name="edit_name"
|
||||
type="text"
|
||||
defaultValue={editPlan.name}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
|
||||
<select
|
||||
value={editPlanType}
|
||||
onChange={(e) => setEditPlanType(e.target.value as 'site' | 'database')}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value="site">Site</option>
|
||||
<option value="database">Database</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Target</label>
|
||||
<select
|
||||
name="edit_target_id"
|
||||
defaultValue={
|
||||
editPlanType === editPlan.plan_type
|
||||
? editPlan.target_id
|
||||
: editPlanType === 'site'
|
||||
? sites[0]?.id
|
||||
: databases[0]?.id
|
||||
}
|
||||
key={editPlanType}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
>
|
||||
{editPlanType === 'site'
|
||||
? sites.map((s) => (
|
||||
<option key={`s-${s.id}`} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))
|
||||
: databases.map((d) => (
|
||||
<option key={`d-${d.id}`} value={d.id}>
|
||||
{d.name} ({d.db_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schedule (cron)</label>
|
||||
<input
|
||||
name="edit_schedule"
|
||||
type="text"
|
||||
defaultValue={editPlan.schedule}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input name="edit_enabled" type="checkbox" defaultChecked={editPlan.enabled} className="rounded" />
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">Enabled</label>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" onClick={() => setEditPlan(null)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add Backup Plan</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Daily site backup"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
|
||||
<select
|
||||
name="plan_type"
|
||||
value={createPlanType}
|
||||
onChange={(e) => setCreatePlanType(e.target.value as 'site' | 'database')}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value="site">Site</option>
|
||||
<option value="database">Database</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Target</label>
|
||||
<select
|
||||
name="target_id"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{createPlanType === 'site'
|
||||
? sites.map((s) => (
|
||||
<option key={`s-${s.id}`} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))
|
||||
: databases.map((d) => (
|
||||
<option key={`d-${d.id}`} value={d.id}>
|
||||
{d.name} ({d.db_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schedule (cron)</label>
|
||||
<input
|
||||
name="schedule"
|
||||
type="text"
|
||||
placeholder="0 2 * * *"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">e.g. 0 2 * * * = daily at 2am, 0 */6 * * * = every 6 hours</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input name="enabled" type="checkbox" defaultChecked className="rounded" />
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">Enabled</label>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={creating} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
245
YakPanel-server/frontend/src/pages/ConfigPage.tsx
Normal file
245
YakPanel-server/frontend/src/pages/ConfigPage.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest, changePassword, testEmail } from '../api/client'
|
||||
import { Save, Key, Mail } from 'lucide-react'
|
||||
|
||||
interface PanelConfig {
|
||||
panel_port: number
|
||||
www_root: string
|
||||
setup_path: string
|
||||
webserver_type: string
|
||||
mysql_root_set: boolean
|
||||
app_name: string
|
||||
app_version: string
|
||||
}
|
||||
|
||||
export function ConfigPage() {
|
||||
const [config, setConfig] = useState<PanelConfig | null>(null)
|
||||
const [configKeys, setConfigKeys] = useState<Record<string, string>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [pwSaved, setPwSaved] = useState(false)
|
||||
const [pwError, setPwError] = useState('')
|
||||
const [testEmailResult, setTestEmailResult] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
apiRequest<PanelConfig>('/config/panel'),
|
||||
apiRequest<Record<string, string>>('/config/keys'),
|
||||
])
|
||||
.then(([cfg, keys]) => {
|
||||
setConfig(cfg)
|
||||
setConfigKeys(keys || {})
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPwSaved(false)
|
||||
setPwError('')
|
||||
const form = e.currentTarget
|
||||
const oldPw = (form.elements.namedItem('old_password') as HTMLInputElement).value
|
||||
const newPw = (form.elements.namedItem('new_password') as HTMLInputElement).value
|
||||
const confirmPw = (form.elements.namedItem('confirm_password') as HTMLInputElement).value
|
||||
if (!oldPw || !newPw) {
|
||||
setPwError('All fields required')
|
||||
return
|
||||
}
|
||||
if (newPw !== confirmPw) {
|
||||
setPwError('New passwords do not match')
|
||||
return
|
||||
}
|
||||
if (newPw.length < 6) {
|
||||
setPwError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
changePassword(oldPw, newPw)
|
||||
.then(() => {
|
||||
setPwSaved(true)
|
||||
form.reset()
|
||||
})
|
||||
.catch((err) => setPwError(err.message))
|
||||
}
|
||||
|
||||
const handleTestEmail = () => {
|
||||
setTestEmailResult(null)
|
||||
testEmail()
|
||||
.then(() => setTestEmailResult('Test email sent!'))
|
||||
.catch((err) => setTestEmailResult(`Failed: ${err.message}`))
|
||||
}
|
||||
|
||||
const handleSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaved(false)
|
||||
const form = e.currentTarget
|
||||
const port = (form.elements.namedItem('panel_port') as HTMLInputElement).value
|
||||
const wwwRoot = (form.elements.namedItem('www_root') as HTMLInputElement).value
|
||||
const setupPath = (form.elements.namedItem('setup_path') as HTMLInputElement).value
|
||||
const webserver = (form.elements.namedItem('webserver_type') as HTMLSelectElement).value
|
||||
const mysqlRoot = (form.elements.namedItem('mysql_root') as HTMLInputElement).value
|
||||
const emailTo = (form.elements.namedItem('email_to') as HTMLInputElement)?.value || ''
|
||||
const smtpServer = (form.elements.namedItem('smtp_server') as HTMLInputElement)?.value || ''
|
||||
const smtpPort = (form.elements.namedItem('smtp_port') as HTMLInputElement)?.value || '587'
|
||||
const smtpUser = (form.elements.namedItem('smtp_user') as HTMLInputElement)?.value || ''
|
||||
const smtpPassword = (form.elements.namedItem('smtp_password') as HTMLInputElement)?.value || ''
|
||||
|
||||
const promises: Promise<unknown>[] = [
|
||||
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'panel_port', value: port }) }),
|
||||
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'www_root', value: wwwRoot }) }),
|
||||
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'setup_path', value: setupPath }) }),
|
||||
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'webserver_type', value: webserver }) }),
|
||||
]
|
||||
if (mysqlRoot) {
|
||||
promises.push(apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'mysql_root', value: mysqlRoot }) }))
|
||||
}
|
||||
promises.push(
|
||||
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'email_to', value: emailTo }) }),
|
||||
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'smtp_server', value: smtpServer }) }),
|
||||
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'smtp_port', value: smtpPort }) }),
|
||||
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'smtp_user', value: smtpUser }) }),
|
||||
)
|
||||
if (smtpPassword) {
|
||||
promises.push(apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'smtp_password', value: smtpPassword }) }))
|
||||
}
|
||||
Promise.all(promises)
|
||||
.then(() => setSaved(true))
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
if (!config) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6 text-gray-800 dark:text-white">Settings</h1>
|
||||
|
||||
<form onSubmit={handleSave} className="max-w-xl space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Panel</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Panel Port</label>
|
||||
<input name="panel_port" type="number" defaultValue={config.panel_port} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">WWW Root</label>
|
||||
<input name="www_root" type="text" defaultValue={config.www_root} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Setup Path</label>
|
||||
<input name="setup_path" type="text" defaultValue={config.setup_path} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webserver</label>
|
||||
<select name="webserver_type" defaultValue={config.webserver_type} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700">
|
||||
<option value="nginx">Nginx</option>
|
||||
<option value="apache">Apache</option>
|
||||
<option value="openlitespeed">OpenLiteSpeed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Notifications (Email)</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email To</label>
|
||||
<input name="email_to" type="email" defaultValue={configKeys.email_to} placeholder="admin@example.com" className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Server</label>
|
||||
<input name="smtp_server" type="text" defaultValue={configKeys.smtp_server} placeholder="smtp.gmail.com" className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
|
||||
<input name="smtp_port" type="number" defaultValue={configKeys.smtp_port || '587'} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP User</label>
|
||||
<input name="smtp_user" type="text" defaultValue={configKeys.smtp_user} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Password</label>
|
||||
<input name="smtp_password" type="password" placeholder={configKeys.smtp_password ? '•••••••• (leave blank to keep)' : 'Optional'} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Used for panel alerts (e.g. backup completion, security warnings).</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestEmail}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-sm"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
Send Test Email
|
||||
</button>
|
||||
{testEmailResult && (
|
||||
<p className={`text-sm ${testEmailResult.startsWith('Failed') ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{testEmailResult}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">MySQL</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Root Password</label>
|
||||
<input
|
||||
name="mysql_root"
|
||||
type="password"
|
||||
placeholder={config.mysql_root_set ? '•••••••• (leave blank to keep)' : 'Required for real DB creation'}
|
||||
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Used to create/drop MySQL databases. Leave blank to keep current.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button type="submit" className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium">
|
||||
<Save className="w-4 h-4" />
|
||||
Save
|
||||
</button>
|
||||
{saved && <span className="text-green-600 dark:text-green-400 text-sm">Saved</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 max-w-xl">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Change Password</h2>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
|
||||
{pwError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{pwError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
|
||||
<input name="old_password" type="password" className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
||||
<input name="new_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
|
||||
<input name="confirm_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
|
||||
</div>
|
||||
<button type="submit" className="flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
|
||||
<Key className="w-4 h-4" />
|
||||
Change Password
|
||||
</button>
|
||||
{pwSaved && <span className="text-green-600 dark:text-green-400 text-sm ml-2">Password changed</span>}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-gray-100 dark:bg-gray-700/50 rounded-lg text-sm text-gray-600 dark:text-gray-400">
|
||||
<p><strong>App:</strong> {config.app_name} v{config.app_version}</p>
|
||||
<p className="mt-2">Note: Some settings require a panel restart to take effect.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
205
YakPanel-server/frontend/src/pages/CrontabPage.tsx
Normal file
205
YakPanel-server/frontend/src/pages/CrontabPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest, applyCrontab } from '../api/client'
|
||||
import { Plus, Trash2, Edit2, Zap } from 'lucide-react'
|
||||
|
||||
interface CronJob {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
schedule: string
|
||||
execstr: string
|
||||
}
|
||||
|
||||
const SCHEDULE_PRESETS = [
|
||||
{ label: 'Every minute', value: '* * * * *' },
|
||||
{ label: 'Every 5 min', value: '*/5 * * * *' },
|
||||
{ label: 'Every hour', value: '0 * * * *' },
|
||||
{ label: 'Daily', value: '0 0 * * *' },
|
||||
{ label: 'Weekly', value: '0 0 * * 0' },
|
||||
]
|
||||
|
||||
export function CrontabPage() {
|
||||
const [jobs, setJobs] = useState<CronJob[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editJob, setEditJob] = useState<CronJob | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
const loadJobs = () => {
|
||||
setLoading(true)
|
||||
apiRequest<CronJob[]>('/crontab/list')
|
||||
.then(setJobs)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
|
||||
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
|
||||
const execstr = (form.elements.namedItem('execstr') as HTMLTextAreaElement).value.trim()
|
||||
|
||||
if (!schedule || !execstr) {
|
||||
setFormError('Schedule and command are required')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setFormError('')
|
||||
const body = { name, type: 'shell', schedule, execstr }
|
||||
const promise = editingId
|
||||
? apiRequest(`/crontab/${editingId}`, { method: 'PUT', body: JSON.stringify(body) })
|
||||
: apiRequest('/crontab/create', { method: 'POST', body: JSON.stringify(body) })
|
||||
promise
|
||||
.then(() => {
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
form.reset()
|
||||
loadJobs()
|
||||
})
|
||||
.catch((err) => setFormError(err.message))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
const handleEdit = (job: CronJob) => {
|
||||
setEditingId(job.id)
|
||||
setEditJob(job)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (!confirm('Delete this cron job?')) return
|
||||
apiRequest(`/crontab/${id}`, { method: 'DELETE' })
|
||||
.then(loadJobs)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
setApplying(true)
|
||||
applyCrontab()
|
||||
.then(loadJobs)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setApplying(false))
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Cron</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying || jobs.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
{applying ? 'Applying...' : 'Apply to System'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(null)
|
||||
setEditJob(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Cron
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">
|
||||
{editingId ? 'Edit Cron Job' : 'Create Cron Job'}
|
||||
</h2>
|
||||
<form key={editingId ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
|
||||
{formError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 text-sm">{formError}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name (optional)</label>
|
||||
<input id="cron-name" name="name" type="text" placeholder="My task" defaultValue={editJob?.name} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schedule (cron)</label>
|
||||
<select
|
||||
className="w-full mb-2 px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (v) (document.getElementById('cron-schedule') as HTMLInputElement).value = v
|
||||
}}
|
||||
>
|
||||
<option value="">Select preset...</option>
|
||||
{SCHEDULE_PRESETS.map((p) => (
|
||||
<option key={p.value} value={p.value}>{p.label} ({p.value})</option>
|
||||
))}
|
||||
</select>
|
||||
<input id="cron-schedule" name="schedule" type="text" placeholder="* * * * *" defaultValue={editJob?.schedule} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Command</label>
|
||||
<textarea id="cron-execstr" name="execstr" rows={3} placeholder="/usr/bin/php /www/wwwroot/script.php" defaultValue={editJob?.execstr} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" onClick={() => { setShowForm(false); setEditingId(null); setEditJob(null) }} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium">
|
||||
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
|
||||
Jobs are stored in the panel. Click "Apply to System" to sync them to the system crontab (root).
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Schedule</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Command</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{jobs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">No cron jobs. Click "Add Cron" to create one.</td>
|
||||
</tr>
|
||||
) : (
|
||||
jobs.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white">{j.name || '-'}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400 font-mono text-sm">{j.schedule}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400 font-mono text-sm max-w-md truncate">{j.execstr}</td>
|
||||
<td className="px-4 py-2 text-right flex gap-1 justify-end">
|
||||
<button onClick={() => handleEdit(j)} className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded" title="Edit"><Edit2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDelete(j.id)} className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded" title="Delete"><Trash2 className="w-4 h-4" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
YakPanel-server/frontend/src/pages/DashboardPage.tsx
Normal file
112
YakPanel-server/frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getDashboardStats } from '../api/client'
|
||||
import { Server, Database, Folder, HardDrive, Cpu, MemoryStick } from 'lucide-react'
|
||||
|
||||
interface Stats {
|
||||
site_count: number
|
||||
ftp_count: number
|
||||
database_count: number
|
||||
system: {
|
||||
cpu_percent: number
|
||||
memory_percent: number
|
||||
memory_used_mb: number
|
||||
memory_total_mb: number
|
||||
disk_percent: number
|
||||
disk_used_gb: number
|
||||
disk_total_gb: number
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
getDashboardStats()
|
||||
.then(setStats)
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return <div className="text-gray-500">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6 text-gray-800 dark:text-white">Dashboard</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
icon={<Server className="w-8 h-8" />}
|
||||
title="Websites"
|
||||
value={stats.site_count}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Folder className="w-8 h-8" />}
|
||||
title="FTP Accounts"
|
||||
value={stats.ftp_count}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Database className="w-8 h-8" />}
|
||||
title="Databases"
|
||||
value={stats.database_count}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
icon={<Cpu className="w-8 h-8" />}
|
||||
title="CPU"
|
||||
value={`${stats.system.cpu_percent}%`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<MemoryStick className="w-8 h-8" />}
|
||||
title="Memory"
|
||||
value={`${stats.system.memory_percent}%`}
|
||||
subtitle={`${stats.system.memory_used_mb} / ${stats.system.memory_total_mb} MB`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<HardDrive className="w-8 h-8" />}
|
||||
title="Disk"
|
||||
value={`${stats.system.disk_percent}%`}
|
||||
subtitle={`${stats.system.disk_used_gb} / ${stats.system.disk_total_gb} GB`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{title}</p>
|
||||
<p className="text-xl font-bold text-gray-800 dark:text-white">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
388
YakPanel-server/frontend/src/pages/DatabasePage.tsx
Normal file
388
YakPanel-server/frontend/src/pages/DatabasePage.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
apiRequest,
|
||||
createDatabaseBackup,
|
||||
listDatabaseBackups,
|
||||
restoreDatabaseBackup,
|
||||
downloadDatabaseBackup,
|
||||
updateDatabasePassword,
|
||||
} from '../api/client'
|
||||
import { Plus, Trash2, Archive, Download, RotateCcw, Key } from 'lucide-react'
|
||||
|
||||
interface DbRecord {
|
||||
id: number
|
||||
name: string
|
||||
username: string
|
||||
db_type: string
|
||||
ps: string
|
||||
}
|
||||
|
||||
export function DatabasePage() {
|
||||
const [databases, setDatabases] = useState<DbRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [creatingError, setCreatingError] = useState('')
|
||||
const [backupDbId, setBackupDbId] = useState<number | null>(null)
|
||||
const [backups, setBackups] = useState<{ filename: string; size: number }[]>([])
|
||||
const [backupLoading, setBackupLoading] = useState(false)
|
||||
const [changePwId, setChangePwId] = useState<number | null>(null)
|
||||
const [pwError, setPwError] = useState('')
|
||||
|
||||
const loadDatabases = () => {
|
||||
setLoading(true)
|
||||
apiRequest<DbRecord[]>('/database/list')
|
||||
.then(setDatabases)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDatabases()
|
||||
}, [])
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
|
||||
const username = (form.elements.namedItem('username') as HTMLInputElement).value.trim()
|
||||
const password = (form.elements.namedItem('password') as HTMLInputElement).value
|
||||
const db_type = (form.elements.namedItem('db_type') as HTMLSelectElement).value
|
||||
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
|
||||
|
||||
if (!name || !username || !password) {
|
||||
setCreatingError('Name, username and password are required')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setCreatingError('')
|
||||
apiRequest<{ status: boolean; msg: string }>('/database/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, username, password, db_type, ps }),
|
||||
})
|
||||
.then(() => {
|
||||
setShowCreate(false)
|
||||
form.reset()
|
||||
loadDatabases()
|
||||
})
|
||||
.catch((err) => setCreatingError(err.message))
|
||||
.finally(() => setCreating(false))
|
||||
}
|
||||
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (!confirm(`Delete database "${name}"?`)) return
|
||||
apiRequest<{ status: boolean }>(`/database/${id}`, { method: 'DELETE' })
|
||||
.then(loadDatabases)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const openBackupModal = (dbId: number) => {
|
||||
setBackupDbId(dbId)
|
||||
setBackups([])
|
||||
listDatabaseBackups(dbId)
|
||||
.then((data) => setBackups(data.backups || []))
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleCreateBackup = () => {
|
||||
if (!backupDbId) return
|
||||
setBackupLoading(true)
|
||||
createDatabaseBackup(backupDbId)
|
||||
.then(() => listDatabaseBackups(backupDbId!).then((d) => setBackups(d.backups || [])))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setBackupLoading(false))
|
||||
}
|
||||
|
||||
const handleRestore = (filename: string) => {
|
||||
if (!backupDbId || !confirm(`Restore from ${filename}? This will overwrite the database.`)) return
|
||||
setBackupLoading(true)
|
||||
restoreDatabaseBackup(backupDbId, filename)
|
||||
.then(() => setBackupDbId(null))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setBackupLoading(false))
|
||||
}
|
||||
|
||||
const handleDownloadBackup = (filename: string) => {
|
||||
if (!backupDbId) return
|
||||
downloadDatabaseBackup(backupDbId, filename).catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent, id: number) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const password = (form.elements.namedItem('new_password') as HTMLInputElement).value
|
||||
const confirm = (form.elements.namedItem('confirm_password') as HTMLInputElement).value
|
||||
if (!password || password.length < 6) {
|
||||
setPwError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setPwError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
setPwError('')
|
||||
updateDatabasePassword(id, password)
|
||||
.then(() => setChangePwId(null))
|
||||
.catch((err) => setPwError(err.message))
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Databases</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Database
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Create Database</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
{creatingError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{creatingError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Database Name</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="mydb"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="dbuser"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
|
||||
<select
|
||||
name="db_type"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value="MySQL">MySQL (full support)</option>
|
||||
<option value="PostgreSQL">PostgreSQL (full support)</option>
|
||||
<option value="MongoDB">MongoDB (full support)</option>
|
||||
<option value="Redis">Redis (panel record only)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">MySQL, PostgreSQL, MongoDB: create, delete, backup/restore. MySQL/PostgreSQL/MongoDB: password change.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Note (optional)</label>
|
||||
<input
|
||||
name="ps"
|
||||
type="text"
|
||||
placeholder="My database"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Username</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Note</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{databases.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
No databases. Click "Add Database" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
databases.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white">{d.name}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{d.username}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{d.db_type}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{d.ps || '-'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
{(d.db_type === 'MySQL' || d.db_type === 'PostgreSQL' || d.db_type === 'MongoDB') && (
|
||||
<button
|
||||
onClick={() => openBackupModal(d.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Backup"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{(d.db_type === 'MySQL' || d.db_type === 'PostgreSQL' || d.db_type === 'MongoDB') && (
|
||||
<button
|
||||
onClick={() => setChangePwId(d.id)}
|
||||
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"
|
||||
title="Change password"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(d.id, d.name)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{backupDbId && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Database Backup</h2>
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={backupLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{backupLoading ? 'Creating...' : 'Create Backup'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Existing backups</h3>
|
||||
{backups.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No backups yet</p>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{backups.map((b) => (
|
||||
<li key={b.filename} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="truncate font-mono text-gray-700 dark:text-gray-300 flex-1">
|
||||
{b.filename}
|
||||
</span>
|
||||
<span className="text-gray-500 text-xs flex-shrink-0">{formatSize(b.size)}</span>
|
||||
<span className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleDownloadBackup(b.filename)}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRestore(b.filename)}
|
||||
disabled={backupLoading}
|
||||
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
|
||||
title="Restore"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setBackupDbId(null)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changePwId && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Change Database Password</h2>
|
||||
<form onSubmit={(e) => handleChangePassword(e, changePwId)} className="space-y-4">
|
||||
{pwError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{pwError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
||||
<input name="new_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
|
||||
<input name="confirm_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setChangePwId(null)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
335
YakPanel-server/frontend/src/pages/DockerPage.tsx
Normal file
335
YakPanel-server/frontend/src/pages/DockerPage.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest, listDockerContainers, listDockerImages, dockerPull, dockerRun } from '../api/client'
|
||||
import { Play, Square, RotateCw, Loader2, Plus, Download } from 'lucide-react'
|
||||
|
||||
interface Container {
|
||||
id: string
|
||||
id_full: string
|
||||
image: string
|
||||
status: string
|
||||
names: string
|
||||
ports: string
|
||||
}
|
||||
|
||||
interface DockerImage {
|
||||
repository: string
|
||||
tag: string
|
||||
id: string
|
||||
size: string
|
||||
}
|
||||
|
||||
export function DockerPage() {
|
||||
const [containers, setContainers] = useState<Container[]>([])
|
||||
const [images, setImages] = useState<DockerImage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [actionId, setActionId] = useState<string | null>(null)
|
||||
const [showRun, setShowRun] = useState(false)
|
||||
const [runImage, setRunImage] = useState('')
|
||||
const [runName, setRunName] = useState('')
|
||||
const [runPorts, setRunPorts] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [pullImage, setPullImage] = useState('')
|
||||
const [pulling, setPulling] = useState(false)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
listDockerContainers(),
|
||||
listDockerImages(),
|
||||
])
|
||||
.then(([contData, imgData]) => {
|
||||
setContainers(contData.containers || [])
|
||||
setImages(imgData.images || [])
|
||||
setError(contData.error || imgData.error || '')
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleStart = (id: string) => {
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/docker/${id}/start`, { method: 'POST' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const handleStop = (id: string) => {
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/docker/${id}/stop`, { method: 'POST' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const handleRestart = (id: string) => {
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/docker/${id}/restart`, { method: 'POST' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const isRunning = (status: string) =>
|
||||
status.toLowerCase().startsWith('up') || status.toLowerCase().includes('running')
|
||||
|
||||
const handleRun = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!runImage.trim()) return
|
||||
setRunning(true)
|
||||
dockerRun(runImage.trim(), runName.trim() || undefined, runPorts.trim() || undefined)
|
||||
.then(() => {
|
||||
setShowRun(false)
|
||||
setRunImage('')
|
||||
setRunName('')
|
||||
setRunPorts('')
|
||||
load()
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setRunning(false))
|
||||
}
|
||||
|
||||
const handlePull = () => {
|
||||
if (!pullImage.trim()) return
|
||||
setPulling(true)
|
||||
dockerPull(pullImage.trim())
|
||||
.then(() => {
|
||||
setPullImage('')
|
||||
load()
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setPulling(false))
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Docker</h1>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={pullImage}
|
||||
onChange={(e) => setPullImage(e.target.value)}
|
||||
placeholder="nginx:latest"
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-sm w-40"
|
||||
/>
|
||||
<button
|
||||
onClick={handlePull}
|
||||
disabled={pulling || !pullImage.trim()}
|
||||
className="flex items-center gap-1 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{pulling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
||||
Pull
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRun(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Run Container
|
||||
</button>
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg"
|
||||
>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRun && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Run Container</h2>
|
||||
<form onSubmit={handleRun} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Image</label>
|
||||
<input
|
||||
value={runImage}
|
||||
onChange={(e) => setRunImage(e.target.value)}
|
||||
placeholder="nginx:latest"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name (optional)</label>
|
||||
<input
|
||||
value={runName}
|
||||
onChange={(e) => setRunName(e.target.value)}
|
||||
placeholder="my-nginx"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ports (optional, e.g. 80:80 or 8080:80)</label>
|
||||
<input
|
||||
value={runPorts}
|
||||
onChange={(e) => setRunPorts(e.target.value)}
|
||||
placeholder="80:80"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setShowRun(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={running} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{running ? 'Starting...' : 'Run'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Container
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Image
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Ports
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{containers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
No containers. Install Docker and run some containers.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
containers.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{c.names || c.id}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{c.image}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isRunning(c.status)
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400 text-sm max-w-xs truncate">
|
||||
{c.ports || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
{isRunning(c.status) ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleRestart(c.id_full)}
|
||||
disabled={actionId === c.id_full}
|
||||
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
|
||||
title="Restart"
|
||||
>
|
||||
{actionId === c.id_full ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStop(c.id_full)}
|
||||
disabled={actionId === c.id_full}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleStart(c.id_full)}
|
||||
disabled={actionId === c.id_full}
|
||||
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded disabled:opacity-50"
|
||||
title="Start"
|
||||
>
|
||||
{actionId === c.id_full ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-bold mb-3 text-gray-800 dark:text-white">Images</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{images.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500">No images. Pull one above.</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Repository</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Tag</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Size</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{images.map((img) => (
|
||||
<tr key={img.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{img.repository}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{img.tag}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{img.size}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
onClick={() => { setRunImage(`${img.repository}:${img.tag}`); setShowRun(true) }}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Run"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
215
YakPanel-server/frontend/src/pages/DomainsPage.tsx
Normal file
215
YakPanel-server/frontend/src/pages/DomainsPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest } from '../api/client'
|
||||
import { Shield, Loader2 } from 'lucide-react'
|
||||
|
||||
interface Domain {
|
||||
id: number
|
||||
name: string
|
||||
port: string
|
||||
site_id: number
|
||||
site_name: string
|
||||
site_path: string
|
||||
}
|
||||
|
||||
interface Certificate {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export function DomainsPage() {
|
||||
const [domains, setDomains] = useState<Domain[]>([])
|
||||
const [certificates, setCertificates] = useState<Certificate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [requesting, setRequesting] = useState<string | null>(null)
|
||||
const [requestDomain, setRequestDomain] = useState<Domain | null>(null)
|
||||
const [requestEmail, setRequestEmail] = useState('')
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
apiRequest<Domain[]>('/ssl/domains'),
|
||||
apiRequest<{ certificates: Certificate[] }>('/ssl/certificates'),
|
||||
])
|
||||
.then(([d, c]) => {
|
||||
setDomains(d)
|
||||
setCertificates(c.certificates || [])
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleRequestCert = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!requestDomain) return
|
||||
setRequesting(requestDomain.name)
|
||||
apiRequest<{ status: boolean }>('/ssl/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain: requestDomain.name,
|
||||
webroot: requestDomain.site_path,
|
||||
email: requestEmail,
|
||||
}),
|
||||
})
|
||||
.then(() => {
|
||||
setRequestDomain(null)
|
||||
load()
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setRequesting(null))
|
||||
}
|
||||
|
||||
const hasCert = (domain: string) =>
|
||||
certificates.some((c) => c.name === domain || c.name.startsWith(domain + ' '))
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Domains & SSL</h1>
|
||||
|
||||
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-800 dark:text-amber-200">
|
||||
<p>Request Let's Encrypt certificates for your site domains. Requires certbot and nginx configured for the domain.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<h2 className="px-4 py-2 border-b dark:border-gray-700 font-medium text-gray-800 dark:text-white">
|
||||
Domains (from sites)
|
||||
</h2>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 max-h-80 overflow-y-auto">
|
||||
{domains.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500">No domains. Add a site first.</div>
|
||||
) : (
|
||||
domains.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex items-center justify-between gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<div>
|
||||
<span className="font-mono text-gray-900 dark:text-white">{d.name}</span>
|
||||
{d.port !== '80' && (
|
||||
<span className="ml-2 text-gray-500 text-sm">:{d.port}</span>
|
||||
)}
|
||||
<span className="ml-2 text-gray-500 text-sm">({d.site_name})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasCert(d.name) ? (
|
||||
<span className="text-green-600 dark:text-green-400 text-sm flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" /> Cert
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRequestDomain(d)
|
||||
setRequestEmail('')
|
||||
}}
|
||||
disabled={!!requesting}
|
||||
className="text-sm px-2 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/50 disabled:opacity-50"
|
||||
>
|
||||
{requesting === d.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin inline" />
|
||||
) : (
|
||||
'Request SSL'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<h2 className="px-4 py-2 border-b dark:border-gray-700 font-medium text-gray-800 dark:text-white">
|
||||
Certificates
|
||||
</h2>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 max-h-80 overflow-y-auto">
|
||||
{certificates.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500">No certificates yet</div>
|
||||
) : (
|
||||
certificates.map((c) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="flex items-center gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30"
|
||||
>
|
||||
<Shield className="w-4 h-4 text-green-600 flex-shrink-0" />
|
||||
<span className="font-mono text-gray-900 dark:text-white">{c.name}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requestDomain && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">
|
||||
Request SSL for {requestDomain.name}
|
||||
</h2>
|
||||
<form onSubmit={handleRequestCert} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Domain
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={requestDomain.name}
|
||||
readOnly
|
||||
className="w-full px-4 py-2 border rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Webroot (site path)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={requestDomain.site_path}
|
||||
readOnly
|
||||
className="w-full px-4 py-2 border rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email (for Let's Encrypt)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={requestEmail}
|
||||
onChange={(e) => setRequestEmail(e.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRequestDomain(null)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!!requesting}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{requesting ? 'Requesting...' : 'Request'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
354
YakPanel-server/frontend/src/pages/FilesPage.tsx
Normal file
354
YakPanel-server/frontend/src/pages/FilesPage.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { listFiles, downloadFile, uploadFile, readFile, writeFile, mkdirFile, renameFile, deleteFile } from '../api/client'
|
||||
import { Folder, File, ArrowLeft, Download, Loader2, Upload, Edit2, FolderPlus, Trash2, Pencil, Check } from 'lucide-react'
|
||||
|
||||
interface FileItem {
|
||||
name: string
|
||||
is_dir: boolean
|
||||
size: number
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const TEXT_EXT = ['.txt', '.html', '.htm', '.css', '.js', '.json', '.xml', '.md', '.py', '.php', '.sh', '.conf', '.env']
|
||||
|
||||
export function FilesPage() {
|
||||
const [path, setPath] = useState('/')
|
||||
const [items, setItems] = useState<FileItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [downloading, setDownloading] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [editingFile, setEditingFile] = useState<string | null>(null)
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
const [showMkdir, setShowMkdir] = useState(false)
|
||||
const [mkdirName, setMkdirName] = useState('')
|
||||
const [renaming, setRenaming] = useState<FileItem | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const loadDir = (p: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
listFiles(p)
|
||||
.then((data) => {
|
||||
setPath(data.path)
|
||||
setItems(data.items.sort((a, b) => (a.is_dir === b.is_dir ? 0 : a.is_dir ? -1 : 1)))
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDir(path)
|
||||
}, [])
|
||||
|
||||
const handleNavigate = (item: FileItem) => {
|
||||
if (item.is_dir) {
|
||||
const newPath = path.endsWith('/') ? path + item.name : path + '/' + item.name
|
||||
loadDir(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
const parts = path.replace(/\/$/, '').split('/').filter(Boolean)
|
||||
if (parts.length <= 1) return
|
||||
parts.pop()
|
||||
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/')
|
||||
loadDir(newPath)
|
||||
}
|
||||
|
||||
const handleDownload = (item: FileItem) => {
|
||||
if (item.is_dir) return
|
||||
const fullPath = path.endsWith('/') ? path + item.name : path + '/' + item.name
|
||||
setDownloading(item.name)
|
||||
downloadFile(fullPath)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setDownloading(null))
|
||||
}
|
||||
|
||||
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
uploadFile(path, file)
|
||||
.then(() => loadDir(path))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => {
|
||||
setUploading(false)
|
||||
e.target.value = ''
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = (item: FileItem) => {
|
||||
const fullPath = path.endsWith('/') ? path + item.name : path + '/' + item.name
|
||||
readFile(fullPath)
|
||||
.then((data) => {
|
||||
setEditingFile(fullPath)
|
||||
setEditContent(typeof data.content === 'string' ? data.content : String(data.content))
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingFile) return
|
||||
setSavingEdit(true)
|
||||
writeFile(editingFile, editContent)
|
||||
.then(() => {
|
||||
setEditingFile(null)
|
||||
loadDir(path)
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setSavingEdit(false))
|
||||
}
|
||||
|
||||
const canEdit = (name: string) => TEXT_EXT.some((ext) => name.toLowerCase().endsWith(ext))
|
||||
|
||||
const handleMkdir = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const name = mkdirName.trim()
|
||||
if (!name) return
|
||||
mkdirFile(path, name)
|
||||
.then(() => {
|
||||
setShowMkdir(false)
|
||||
setMkdirName('')
|
||||
loadDir(path)
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleRename = () => {
|
||||
if (!renaming || !renameValue.trim()) return
|
||||
const newName = renameValue.trim()
|
||||
if (newName === renaming.name) {
|
||||
setRenaming(null)
|
||||
return
|
||||
}
|
||||
renameFile(path, renaming.name, newName)
|
||||
.then(() => {
|
||||
setRenaming(null)
|
||||
setRenameValue('')
|
||||
loadDir(path)
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleDelete = (item: FileItem) => {
|
||||
if (!confirm(`Delete ${item.is_dir ? 'folder' : 'file'} "${item.name}"?`)) return
|
||||
deleteFile(path, item.name, item.is_dir)
|
||||
.then(() => loadDir(path))
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const breadcrumbs = path.split('/').filter(Boolean)
|
||||
const canGoBack = breadcrumbs.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Files</h1>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={!canGoBack}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowMkdir(true)}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
New Folder
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50"
|
||||
>
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
Upload
|
||||
</button>
|
||||
<div className="flex items-center gap-1 px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm">
|
||||
<span className="text-gray-500">Path:</span>
|
||||
<span className="text-gray-800 dark:text-white font-mono">{path}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMkdir && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">New Folder</h2>
|
||||
<form onSubmit={handleMkdir} className="space-y-4">
|
||||
<input
|
||||
value={mkdirName}
|
||||
onChange={(e) => setMkdirName(e.target.value)}
|
||||
placeholder="Folder name"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => { setShowMkdir(false); setMkdirName('') }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded-lg">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingFile && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<div className="p-4 border-b dark:border-gray-700 flex justify-between items-center">
|
||||
<span className="font-mono text-sm text-gray-600 dark:text-gray-400">{editingFile}</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setEditingFile(null)} className="px-3 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700">Cancel</button>
|
||||
<button onClick={handleSaveEdit} disabled={savingEdit} className="px-3 py-1 bg-blue-600 text-white rounded disabled:opacity-50">
|
||||
{savingEdit ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="flex-1 p-4 font-mono text-sm bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white resize-none min-h-[400px]"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 flex justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Size</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-8 text-center text-gray-500">
|
||||
Empty directory
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<tr
|
||||
key={item.name}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => handleNavigate(item)}
|
||||
className="flex items-center gap-2 text-left w-full hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{item.is_dir ? (
|
||||
<Folder className="w-5 h-5 text-amber-500" />
|
||||
) : (
|
||||
<File className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<span className="text-gray-900 dark:text-white">{item.name}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">
|
||||
{item.is_dir ? '-' : formatSize(item.size)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
{renaming?.name === item.name ? (
|
||||
<span className="flex gap-1 items-center">
|
||||
<input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||
className="px-2 py-1 text-sm border rounded w-32"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleRename} className="p-1.5 text-green-600 rounded" title="Save">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => { setRenaming(null); setRenameValue('') }} className="p-1.5 text-gray-500 rounded">Cancel</button>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setRenaming(item); setRenameValue(item.name) }}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
title="Rename"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
{!item.is_dir && canEdit(item.name) && (
|
||||
<button
|
||||
onClick={() => handleEdit(item)}
|
||||
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{!item.is_dir && (
|
||||
<button
|
||||
onClick={() => handleDownload(item)}
|
||||
disabled={downloading === item.name}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded disabled:opacity-50"
|
||||
title="Download"
|
||||
>
|
||||
{downloading === item.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
YakPanel-server/frontend/src/pages/FirewallPage.tsx
Normal file
219
YakPanel-server/frontend/src/pages/FirewallPage.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest, applyFirewallRules } from '../api/client'
|
||||
import { Plus, Trash2, Zap } from 'lucide-react'
|
||||
|
||||
interface FirewallRule {
|
||||
id: number
|
||||
port: string
|
||||
protocol: string
|
||||
action: string
|
||||
ps: string
|
||||
}
|
||||
|
||||
export function FirewallPage() {
|
||||
const [rules, setRules] = useState<FirewallRule[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [creatingError, setCreatingError] = useState('')
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
const loadRules = () => {
|
||||
setLoading(true)
|
||||
apiRequest<FirewallRule[]>('/firewall/list')
|
||||
.then(setRules)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRules()
|
||||
}, [])
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const port = (form.elements.namedItem('port') as HTMLInputElement).value.trim()
|
||||
const protocol = (form.elements.namedItem('protocol') as HTMLSelectElement).value
|
||||
const action = (form.elements.namedItem('action') as HTMLSelectElement).value
|
||||
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
|
||||
|
||||
if (!port) {
|
||||
setCreatingError('Port is required')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setCreatingError('')
|
||||
apiRequest<{ status: boolean; msg: string }>('/firewall/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ port, protocol, action, ps }),
|
||||
})
|
||||
.then(() => {
|
||||
setShowCreate(false)
|
||||
form.reset()
|
||||
loadRules()
|
||||
})
|
||||
.catch((err) => setCreatingError(err.message))
|
||||
.finally(() => setCreating(false))
|
||||
}
|
||||
|
||||
const handleDelete = (id: number, port: string) => {
|
||||
if (!confirm(`Delete rule for port ${port}?`)) return
|
||||
apiRequest<{ status: boolean }>(`/firewall/${id}`, { method: 'DELETE' })
|
||||
.then(loadRules)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
setApplying(true)
|
||||
applyFirewallRules()
|
||||
.then(() => loadRules())
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setApplying(false))
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Security / Firewall</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying || rules.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
{applying ? 'Applying...' : 'Apply to UFW'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
|
||||
Rules are stored in the panel. Click "Apply to UFW" to run <code className="font-mono">ufw allow/deny</code> for each rule.
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add Firewall Rule</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
{creatingError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{creatingError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Port</label>
|
||||
<input
|
||||
name="port"
|
||||
type="text"
|
||||
placeholder="80 or 80-90 or 80,443"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Protocol</label>
|
||||
<select
|
||||
name="protocol"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Action</label>
|
||||
<select
|
||||
name="action"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value="accept">Accept</option>
|
||||
<option value="drop">Drop</option>
|
||||
<option value="reject">Reject</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Note (optional)</label>
|
||||
<input
|
||||
name="ps"
|
||||
type="text"
|
||||
placeholder="HTTP"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
{creating ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Port</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Protocol</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Action</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Note</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{rules.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
No rules. Click "Add Rule" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rules.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white font-mono">{r.port}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{r.protocol}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{r.action}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{r.ps || '-'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(r.id, r.port)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
YakPanel-server/frontend/src/pages/FtpPage.tsx
Normal file
257
YakPanel-server/frontend/src/pages/FtpPage.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest, updateFtpPassword } from '../api/client'
|
||||
import { Plus, Trash2, Key } from 'lucide-react'
|
||||
|
||||
interface FtpAccount {
|
||||
id: number
|
||||
name: string
|
||||
path: string
|
||||
ps: string
|
||||
}
|
||||
|
||||
export function FtpPage() {
|
||||
const [accounts, setAccounts] = useState<FtpAccount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [creatingError, setCreatingError] = useState('')
|
||||
const [changePwId, setChangePwId] = useState<number | null>(null)
|
||||
const [pwError, setPwError] = useState('')
|
||||
|
||||
const loadAccounts = () => {
|
||||
setLoading(true)
|
||||
apiRequest<FtpAccount[]>('/ftp/list')
|
||||
.then(setAccounts)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
}, [])
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
|
||||
const password = (form.elements.namedItem('password') as HTMLInputElement).value
|
||||
const path = (form.elements.namedItem('path') as HTMLInputElement).value.trim()
|
||||
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
|
||||
|
||||
if (!name || !password || !path) {
|
||||
setCreatingError('Name, password and path are required')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setCreatingError('')
|
||||
apiRequest<{ status: boolean; msg: string }>('/ftp/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, password, path, ps }),
|
||||
})
|
||||
.then(() => {
|
||||
setShowCreate(false)
|
||||
form.reset()
|
||||
loadAccounts()
|
||||
})
|
||||
.catch((err) => setCreatingError(err.message))
|
||||
.finally(() => setCreating(false))
|
||||
}
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent, id: number) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const password = (form.elements.namedItem('new_password') as HTMLInputElement).value
|
||||
const confirm = (form.elements.namedItem('confirm_password') as HTMLInputElement).value
|
||||
if (!password || password.length < 6) {
|
||||
setPwError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setPwError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
setPwError('')
|
||||
updateFtpPassword(id, password)
|
||||
.then(() => setChangePwId(null))
|
||||
.catch((err) => setPwError(err.message))
|
||||
}
|
||||
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (!confirm(`Delete FTP account "${name}"?`)) return
|
||||
apiRequest<{ status: boolean }>(`/ftp/${id}`, { method: 'DELETE' })
|
||||
.then(loadAccounts)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">FTP</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add FTP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
|
||||
FTP accounts use Pure-FTPd (pure-pw). Path must be under www root. Install: <code className="font-mono">apt install pure-ftpd pure-ftpd-common</code>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Create FTP Account</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
{creatingError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{creatingError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="ftpuser"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Path</label>
|
||||
<input
|
||||
name="path"
|
||||
type="text"
|
||||
placeholder="/www/wwwroot"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Note (optional)</label>
|
||||
<input
|
||||
name="ps"
|
||||
type="text"
|
||||
placeholder="My FTP"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Path</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Note</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{accounts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
|
||||
No FTP accounts. Click "Add FTP" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
accounts.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white">{a.name}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{a.path}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{a.ps || '-'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => setChangePwId(a.id)}
|
||||
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"
|
||||
title="Change password"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(a.id, a.name)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{changePwId && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Change FTP Password</h2>
|
||||
<form onSubmit={(e) => handleChangePassword(e, changePwId)} className="space-y-4">
|
||||
{pwError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{pwError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
||||
<input name="new_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
|
||||
<input name="confirm_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setChangePwId(null)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
YakPanel-server/frontend/src/pages/LoginPage.tsx
Normal file
81
YakPanel-server/frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { login } from '../api/client'
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="w-full max-w-md p-8 bg-white dark:bg-gray-800 rounded-xl shadow-lg">
|
||||
<h1 className="text-2xl font-bold text-center mb-6 text-gray-800 dark:text-white">
|
||||
YakPanel
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
Default: admin / admin
|
||||
</p>
|
||||
<p className="mt-2 text-center text-sm">
|
||||
<a href="/install" className="text-blue-600 hover:underline dark:text-blue-400">
|
||||
Remote SSH install (optional)
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
192
YakPanel-server/frontend/src/pages/LogsPage.tsx
Normal file
192
YakPanel-server/frontend/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { listLogs, readLog } from '../api/client'
|
||||
import { Folder, File, ArrowLeft, Loader2, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface LogItem {
|
||||
name: string
|
||||
is_dir: boolean
|
||||
size: number
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
const [path, setPath] = useState('/')
|
||||
const [items, setItems] = useState<LogItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [viewingFile, setViewingFile] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState('')
|
||||
const [fileLoading, setFileLoading] = useState(false)
|
||||
const [tailLines, setTailLines] = useState(500)
|
||||
|
||||
const loadDir = (p: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
listLogs(p)
|
||||
.then((data) => {
|
||||
setPath(data.path)
|
||||
setItems(data.items.sort((a, b) => (a.is_dir === b.is_dir ? 0 : a.is_dir ? -1 : 1)))
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDir(path)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewingFile) {
|
||||
setFileLoading(true)
|
||||
readLog(viewingFile, tailLines)
|
||||
.then((data) => setFileContent(data.content))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setFileLoading(false))
|
||||
}
|
||||
}, [viewingFile, tailLines])
|
||||
|
||||
const handleNavigate = (item: LogItem) => {
|
||||
if (item.is_dir) {
|
||||
const newPath = path === '/' ? '/' + item.name : path + '/' + item.name
|
||||
loadDir(newPath)
|
||||
} else {
|
||||
setViewingFile(path === '/' ? item.name : path + '/' + item.name)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
const parts = path.replace(/\/$/, '').split('/').filter(Boolean)
|
||||
if (parts.length <= 1) return
|
||||
parts.pop()
|
||||
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/')
|
||||
loadDir(newPath)
|
||||
}
|
||||
|
||||
const handleRefreshFile = () => {
|
||||
if (!viewingFile) return
|
||||
setFileLoading(true)
|
||||
readLog(viewingFile, tailLines)
|
||||
.then((data) => setFileContent(data.content))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setFileLoading(false))
|
||||
}
|
||||
|
||||
const breadcrumbs = path.split('/').filter(Boolean)
|
||||
const canGoBack = breadcrumbs.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Logs</h1>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={!canGoBack}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<div className="flex items-center gap-1 px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm">
|
||||
<span className="text-gray-500">Path:</span>
|
||||
<span className="text-gray-800 dark:text-white font-mono">{path || '/'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="px-4 py-2 border-b dark:border-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Log files
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="p-8 flex justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 max-h-[500px] overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500">Empty directory</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() => handleNavigate(item)}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
{item.is_dir ? (
|
||||
<Folder className="w-5 h-5 text-amber-500 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-gray-900 dark:text-white truncate">{item.name}</span>
|
||||
{!item.is_dir && (
|
||||
<span className="ml-auto text-gray-500 text-sm flex-shrink-0">
|
||||
{formatSize(item.size)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden flex flex-col">
|
||||
<div className="px-4 py-2 border-b dark:border-gray-700 flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{viewingFile || 'Select a log file'}
|
||||
</span>
|
||||
{viewingFile && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<label className="text-xs text-gray-500">Lines:</label>
|
||||
<select
|
||||
value={tailLines}
|
||||
onChange={(e) => setTailLines(Number(e.target.value))}
|
||||
className="text-sm border rounded px-2 py-1 bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value={100}>100</option>
|
||||
<option value={500}>500</option>
|
||||
<option value={1000}>1000</option>
|
||||
<option value={5000}>5000</option>
|
||||
<option value={10000}>10000</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleRefreshFile}
|
||||
disabled={fileLoading}
|
||||
className="p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${fileLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 min-h-[400px]">
|
||||
{!viewingFile ? (
|
||||
<div className="text-gray-500 text-sm">Click a log file to view</div>
|
||||
) : fileLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<pre className="font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all">
|
||||
{fileContent || '(empty)'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
199
YakPanel-server/frontend/src/pages/MonitorPage.tsx
Normal file
199
YakPanel-server/frontend/src/pages/MonitorPage.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest, getMonitorProcesses, getMonitorNetwork } from '../api/client'
|
||||
import { Cpu, HardDrive, MemoryStick, Activity, Network } from 'lucide-react'
|
||||
|
||||
interface SystemStats {
|
||||
cpu_percent: number
|
||||
memory_percent: number
|
||||
memory_used_mb: number
|
||||
memory_total_mb: number
|
||||
disk_percent: number
|
||||
disk_used_gb: number
|
||||
disk_total_gb: number
|
||||
}
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
name: string
|
||||
username: string
|
||||
cpu_percent: number
|
||||
memory_percent: number
|
||||
status: string
|
||||
}
|
||||
|
||||
interface NetworkStats {
|
||||
bytes_sent_mb: number
|
||||
bytes_recv_mb: number
|
||||
}
|
||||
|
||||
export function MonitorPage() {
|
||||
const [stats, setStats] = useState<SystemStats | null>(null)
|
||||
const [processes, setProcesses] = useState<ProcessInfo[]>([])
|
||||
const [network, setNetwork] = useState<NetworkStats | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = () => {
|
||||
apiRequest<SystemStats>('/monitor/system')
|
||||
.then(setStats)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
const fetchProcesses = () => {
|
||||
getMonitorProcesses(50)
|
||||
.then((d) => setProcesses(d.processes))
|
||||
.catch(() => setProcesses([]))
|
||||
}
|
||||
const fetchNetwork = () => {
|
||||
getMonitorNetwork()
|
||||
.then(setNetwork)
|
||||
.catch(() => setNetwork(null))
|
||||
}
|
||||
fetchStats()
|
||||
fetchProcesses()
|
||||
fetchNetwork()
|
||||
const interval = setInterval(() => {
|
||||
fetchStats()
|
||||
fetchProcesses()
|
||||
fetchNetwork()
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
if (!stats) return <div className="text-gray-500">Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6 text-gray-800 dark:text-white">Monitor</h1>
|
||||
<p className="text-sm text-gray-500 mb-4">Refreshes every 3 seconds</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<StatCard
|
||||
icon={<Cpu className="w-10 h-10" />}
|
||||
title="CPU"
|
||||
value={`${stats.cpu_percent}%`}
|
||||
subtitle="Usage"
|
||||
percent={stats.cpu_percent}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<MemoryStick className="w-10 h-10" />}
|
||||
title="Memory"
|
||||
value={`${stats.memory_used_mb} / ${stats.memory_total_mb} MB`}
|
||||
subtitle={`${stats.memory_percent}% used`}
|
||||
percent={stats.memory_percent}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<HardDrive className="w-10 h-10" />}
|
||||
title="Disk"
|
||||
value={`${stats.disk_used_gb} / ${stats.disk_total_gb} GB`}
|
||||
subtitle={`${stats.disk_percent}% used`}
|
||||
percent={stats.disk_percent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{network && (
|
||||
<div className="mt-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-3 text-gray-800 dark:text-white">
|
||||
<Network className="w-5 h-5" />
|
||||
<span className="font-medium">Network I/O</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Sent</span>
|
||||
<p className="font-mono font-medium">{network.bytes_sent_mb} MB</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Received</span>
|
||||
<p className="font-mono font-medium">{network.bytes_recv_mb} MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<Cpu className="w-5 h-5" />
|
||||
<span className="font-medium text-gray-800 dark:text-white">Top Processes (by CPU)</span>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300">PID</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300">User</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300">CPU %</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300">Mem %</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{processes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-4 text-center text-gray-500 text-sm">No process data</td>
|
||||
</tr>
|
||||
) : (
|
||||
processes.map((p) => (
|
||||
<tr key={p.pid} className="text-sm">
|
||||
<td className="px-4 py-1.5 font-mono text-gray-700 dark:text-gray-300">{p.pid}</td>
|
||||
<td className="px-4 py-1.5 text-gray-900 dark:text-white truncate max-w-[120px]" title={p.name}>{p.name}</td>
|
||||
<td className="px-4 py-1.5 text-gray-600 dark:text-gray-400">{p.username}</td>
|
||||
<td className="px-4 py-1.5 text-right font-mono">{p.cpu_percent}%</td>
|
||||
<td className="px-4 py-1.5 text-right font-mono">{p.memory_percent}%</td>
|
||||
<td className="px-4 py-1.5 text-gray-500 text-xs">{p.status}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-center gap-2 text-amber-800 dark:text-amber-200">
|
||||
<Activity className="w-5 h-5" />
|
||||
<span className="font-medium">Live monitoring</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
System metrics, processes, and network stats are polled every 3 seconds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
percent,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
value: string
|
||||
subtitle: string
|
||||
percent: number
|
||||
}) {
|
||||
const barColor = percent > 90 ? 'bg-red-500' : percent > 70 ? 'bg-amber-500' : 'bg-blue-500'
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-lg bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{title}</p>
|
||||
<p className="text-xl font-bold text-gray-800 dark:text-white">{value}</p>
|
||||
<p className="text-xs text-gray-500">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${barColor} transition-all duration-500`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
298
YakPanel-server/frontend/src/pages/NodePage.tsx
Normal file
298
YakPanel-server/frontend/src/pages/NodePage.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest, nodeAddProcess } from '../api/client'
|
||||
import { Play, Square, RotateCw, Trash2, Loader2, Plus } from 'lucide-react'
|
||||
|
||||
interface Pm2Process {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
pid: number | null
|
||||
uptime: number
|
||||
restarts: number
|
||||
memory: number
|
||||
cpu: number
|
||||
}
|
||||
|
||||
function formatUptime(ms: number): string {
|
||||
if (!ms || ms < 0) return '-'
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m`
|
||||
const h = Math.floor(m / 60)
|
||||
return `${h}h`
|
||||
}
|
||||
|
||||
function formatMemory(bytes: number): string {
|
||||
if (!bytes) return '-'
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
export function NodePage() {
|
||||
const [processes, setProcesses] = useState<Pm2Process[]>([])
|
||||
const [nodeVersion, setNodeVersion] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [actionId, setActionId] = useState<number | null>(null)
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [addScript, setAddScript] = useState('')
|
||||
const [addName, setAddName] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
apiRequest<{ processes: Pm2Process[]; error?: string }>('/node/processes'),
|
||||
apiRequest<{ version: string | null; error?: string }>('/node/version'),
|
||||
])
|
||||
.then(([procData, verData]) => {
|
||||
setProcesses(procData.processes || [])
|
||||
setNodeVersion(verData.version || null)
|
||||
setError(procData.error || verData.error || '')
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleStart = (id: number) => {
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/node/${id}/start`, { method: 'POST' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const handleStop = (id: number) => {
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/node/${id}/stop`, { method: 'POST' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const handleRestart = (id: number) => {
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/node/${id}/restart`, { method: 'POST' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!addScript.trim()) return
|
||||
setAdding(true)
|
||||
nodeAddProcess(addScript.trim(), addName.trim() || undefined)
|
||||
.then(() => {
|
||||
setShowAdd(false)
|
||||
setAddScript('')
|
||||
setAddName('')
|
||||
load()
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setAdding(false))
|
||||
}
|
||||
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (!confirm(`Delete PM2 process "${name}"?`)) return
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/node/${id}`, { method: 'DELETE' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const isOnline = (status: string) =>
|
||||
status?.toLowerCase() === 'online' || status?.toLowerCase() === 'launching'
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Node.js</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{nodeVersion && (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Node: {nodeVersion}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Process
|
||||
</button>
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg"
|
||||
>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add PM2 Process</h2>
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Script path</label>
|
||||
<input
|
||||
value={addScript}
|
||||
onChange={(e) => setAddScript(e.target.value)}
|
||||
placeholder="/www/wwwroot/app.js"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name (optional)</label>
|
||||
<input
|
||||
value={addName}
|
||||
onChange={(e) => setAddName(e.target.value)}
|
||||
placeholder="myapp"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setShowAdd(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={adding} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{adding ? 'Starting...' : 'Start'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
|
||||
PM2 process manager. Install with: <code className="font-mono">npm install -g pm2</code>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
PID
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Uptime
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Memory
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{processes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
No PM2 processes. Click "Add Process" or run <code className="font-mono">pm2 start app.js</code>.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
processes.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{p.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isOnline(p.status)
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{p.pid || '-'}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">
|
||||
{formatUptime(p.uptime)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">
|
||||
{formatMemory(p.memory)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
{isOnline(p.status) ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleRestart(p.id)}
|
||||
disabled={actionId === p.id}
|
||||
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
|
||||
title="Restart"
|
||||
>
|
||||
{actionId === p.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStop(p.id)}
|
||||
disabled={actionId === p.id}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleStart(p.id)}
|
||||
disabled={actionId === p.id}
|
||||
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded disabled:opacity-50"
|
||||
title="Start"
|
||||
>
|
||||
{actionId === p.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(p.id, p.name)}
|
||||
disabled={actionId === p.id}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded disabled:opacity-50"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
YakPanel-server/frontend/src/pages/PluginsPage.tsx
Normal file
142
YakPanel-server/frontend/src/pages/PluginsPage.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest, addPluginFromUrl, deletePlugin } from '../api/client'
|
||||
import { Puzzle, Check, Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
interface Plugin {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
desc: string
|
||||
enabled: boolean
|
||||
builtin?: boolean
|
||||
db_id?: number
|
||||
}
|
||||
|
||||
export function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<Plugin[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [addUrl, setAddUrl] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState('')
|
||||
|
||||
const loadPlugins = () =>
|
||||
apiRequest<{ plugins: Plugin[] }>('/plugin/list')
|
||||
.then((data) => setPlugins(data.plugins || []))
|
||||
.catch((err) => setError(err.message))
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
loadPlugins().finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const url = addUrl.trim()
|
||||
if (!url) return
|
||||
setAdding(true)
|
||||
setAddError('')
|
||||
addPluginFromUrl(url)
|
||||
.then(() => {
|
||||
setShowAdd(false)
|
||||
setAddUrl('')
|
||||
loadPlugins()
|
||||
})
|
||||
.catch((err) => setAddError(err.message))
|
||||
.finally(() => setAdding(false))
|
||||
}
|
||||
|
||||
const handleDelete = (pluginId: string) => {
|
||||
if (!confirm('Remove this plugin?')) return
|
||||
deletePlugin(pluginId)
|
||||
.then(loadPlugins)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Plugins</h1>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add from URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
|
||||
Built-in extensions and third-party plugins. Add plugins from a JSON manifest URL (must include <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">id</code>, <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">name</code>, and optionally <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">version</code>, <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">desc</code>).
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add Plugin from URL</h2>
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
{addError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">{addError}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Manifest URL</label>
|
||||
<input
|
||||
value={addUrl}
|
||||
onChange={(e) => setAddUrl(e.target.value)}
|
||||
placeholder="https://example.com/plugin.json"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => { setShowAdd(false); setAddError('') }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={adding} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{adding ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{plugins.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-4 flex items-start gap-3"
|
||||
>
|
||||
<Puzzle className="w-8 h-8 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{p.name}</h3>
|
||||
{p.enabled && (
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs">
|
||||
<Check className="w-3 h-3" />
|
||||
Enabled
|
||||
</span>
|
||||
)}
|
||||
{!p.builtin && (
|
||||
<button
|
||||
onClick={() => handleDelete(p.id)}
|
||||
className="p-1 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded ml-auto"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{p.desc}</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">v{p.version}{p.builtin ? ' (built-in)' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
295
YakPanel-server/frontend/src/pages/RemoteInstallPage.tsx
Normal file
295
YakPanel-server/frontend/src/pages/RemoteInstallPage.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type InstallerConfig = { enabled: boolean; default_install_url: string }
|
||||
|
||||
export function RemoteInstallPage() {
|
||||
const [cfg, setCfg] = useState<InstallerConfig | null>(null)
|
||||
const [cfgError, setCfgError] = useState('')
|
||||
const [host, setHost] = useState('')
|
||||
const [port, setPort] = useState('22')
|
||||
const [username, setUsername] = useState('root')
|
||||
const [authType, setAuthType] = useState<'key' | 'password'>('key')
|
||||
const [privateKey, setPrivateKey] = useState('')
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [installUrl, setInstallUrl] = useState('')
|
||||
const [log, setLog] = useState<string[]>([])
|
||||
const [running, setRunning] = useState(false)
|
||||
const [exitCode, setExitCode] = useState<number | null>(null)
|
||||
const [formError, setFormError] = useState('')
|
||||
const logRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/public-install/config')
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((d: InstallerConfig) => {
|
||||
setCfg(d)
|
||||
setInstallUrl(d.default_install_url || '')
|
||||
})
|
||||
.catch(() => setCfgError('Could not load installer configuration'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
|
||||
}, [log])
|
||||
|
||||
const appendLog = useCallback((line: string) => {
|
||||
setLog((prev) => [...prev.slice(-2000), line])
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setFormError('')
|
||||
setLog([])
|
||||
setExitCode(null)
|
||||
if (!cfg?.enabled) return
|
||||
|
||||
const urlField = installUrl.trim() || cfg.default_install_url
|
||||
const auth =
|
||||
authType === 'key'
|
||||
? { type: 'key' as const, private_key: privateKey, passphrase: passphrase || null }
|
||||
: { type: 'password' as const, password }
|
||||
|
||||
const body = {
|
||||
host: host.trim(),
|
||||
port: parseInt(port, 10) || 22,
|
||||
username: username.trim(),
|
||||
auth,
|
||||
install_url: urlField === cfg.default_install_url ? null : urlField,
|
||||
}
|
||||
|
||||
setRunning(true)
|
||||
try {
|
||||
const res = await fetch('/api/v1/public-install/jobs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || `HTTP ${res.status}`)
|
||||
}
|
||||
const { job_id } = (await res.json()) as { job_id: string }
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${proto}//${window.location.host}/api/v1/public-install/ws/${job_id}`)
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data as string)
|
||||
if (data.type === 'line' && typeof data.text === 'string') appendLog(data.text)
|
||||
if (data.type === 'done') setExitCode(typeof data.exit_code === 'number' ? data.exit_code : -1)
|
||||
} catch {
|
||||
appendLog(String(ev.data))
|
||||
}
|
||||
}
|
||||
ws.onerror = () => appendLog('[websocket error]')
|
||||
ws.onclose = () => setRunning(false)
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : 'Request failed')
|
||||
setRunning(false)
|
||||
} finally {
|
||||
if (authType === 'password') setPassword('')
|
||||
}
|
||||
}
|
||||
|
||||
if (!cfg && !cfgError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<p className="text-gray-500">Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (cfgError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4">
|
||||
<p className="text-red-600">{cfgError}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (cfg && !cfg.enabled) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4">
|
||||
<div className="w-full max-w-lg p-8 bg-white dark:bg-gray-800 rounded-xl shadow-lg space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Remote SSH installer disabled</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||
Enable it on the API server with environment variable{' '}
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">ENABLE_REMOTE_INSTALLER=true</code> and restart
|
||||
the backend. Prefer SSH keys; exposing this endpoint increases risk.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
One-liner on the target server (as root):{' '}
|
||||
<code className="break-all block mt-2 bg-gray-900 text-green-400 p-2 rounded text-xs">
|
||||
curl -fsSL {cfg.default_install_url} | bash
|
||||
</code>
|
||||
</p>
|
||||
<Link to="/login" className="inline-block text-blue-600 hover:underline text-sm">
|
||||
Panel login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 py-8 px-4">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="bg-amber-100 dark:bg-amber-900/40 border border-amber-300 dark:border-amber-700 rounded-lg p-4 text-sm text-amber-950 dark:text-amber-100">
|
||||
<strong>Security warning:</strong> SSH credentials are sent to this panel API and used only for this session
|
||||
(not stored). Prefer <strong>SSH private keys</strong>. Root password SSH is often disabled on Ubuntu. Non-root
|
||||
users need <strong>passwordless sudo</strong> (<code>sudo -n</code>) to run the installer. Allow SSH from this
|
||||
server to the target on port {port}.
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Remote install (SSH)</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Runs the published <code>install.sh</code> on the target via SSH (same as shell one-liner).
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{formError && (
|
||||
<div className="p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Host</label>
|
||||
<input
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="203.0.113.50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SSH port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SSH username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication</span>
|
||||
<div className="flex gap-4">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-800 dark:text-gray-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="auth"
|
||||
checked={authType === 'key'}
|
||||
onChange={() => setAuthType('key')}
|
||||
/>
|
||||
Private key
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-800 dark:text-gray-200">
|
||||
<input
|
||||
type="radio"
|
||||
name="auth"
|
||||
checked={authType === 'password'}
|
||||
onChange={() => setAuthType('password')}
|
||||
/>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{authType === 'key' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Private key (PEM)</label>
|
||||
<textarea
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-xs"
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Key passphrase (optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SSH password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Install script URL (https only)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={installUrl}
|
||||
onChange={(e) => setInstallUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={running || !cfg?.enabled || !cfg}
|
||||
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
{running ? 'Running…' : 'Start remote install'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{log.length > 0 && (
|
||||
<div className="bg-gray-900 text-gray-100 rounded-xl p-4 font-mono text-xs overflow-hidden">
|
||||
<pre ref={logRef} className="max-h-96 overflow-auto whitespace-pre-wrap break-words">
|
||||
{log.join('\n')}
|
||||
</pre>
|
||||
{exitCode !== null && (
|
||||
<p className="mt-2 text-sm border-t border-gray-700 pt-2">
|
||||
Exit code: <strong>{exitCode}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
<Link to="/login" className="text-blue-600 hover:underline">
|
||||
Panel login
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
YakPanel-server/frontend/src/pages/ServicesPage.tsx
Normal file
145
YakPanel-server/frontend/src/pages/ServicesPage.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest } from '../api/client'
|
||||
import { Play, Square, RotateCw, Loader2 } from 'lucide-react'
|
||||
|
||||
interface Service {
|
||||
id: string
|
||||
name: string
|
||||
unit: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export function ServicesPage() {
|
||||
const [services, setServices] = useState<Service[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [actionId, setActionId] = useState<string | null>(null)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
apiRequest<{ services: Service[] }>('/service/list')
|
||||
.then((data) => setServices(data.services || []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleStart = (id: string) => {
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/service/${id}/start`, { method: 'POST' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const handleStop = (id: string) => {
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/service/${id}/stop`, { method: 'POST' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const handleRestart = (id: string) => {
|
||||
setActionId(id)
|
||||
apiRequest<{ status: boolean }>(`/service/${id}/restart`, { method: 'POST' })
|
||||
.then(load)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const isActive = (status: string) => status === 'active' || status === 'activating'
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Services</h1>
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg"
|
||||
>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
|
||||
Control system services via systemctl. Requires panel to run with sufficient privileges.
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Service</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Unit</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{services.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white">{s.name}</td>
|
||||
<td className="px-4 py-2 font-mono text-gray-600 dark:text-gray-400 text-sm">{s.unit}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isActive(s.status) ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{s.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
{isActive(s.status) ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleRestart(s.id)}
|
||||
disabled={!!actionId}
|
||||
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
|
||||
title="Restart"
|
||||
>
|
||||
{actionId === s.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <RotateCw className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStop(s.id)}
|
||||
disabled={!!actionId}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleStart(s.id)}
|
||||
disabled={!!actionId}
|
||||
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded disabled:opacity-50"
|
||||
title="Start"
|
||||
>
|
||||
{actionId === s.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
697
YakPanel-server/frontend/src/pages/SitePage.tsx
Normal file
697
YakPanel-server/frontend/src/pages/SitePage.tsx
Normal file
@@ -0,0 +1,697 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
apiRequest,
|
||||
createSite,
|
||||
getSite,
|
||||
updateSite,
|
||||
setSiteStatus,
|
||||
deleteSite,
|
||||
createSiteBackup,
|
||||
listSiteBackups,
|
||||
restoreSiteBackup,
|
||||
downloadSiteBackup,
|
||||
listSiteRedirects,
|
||||
addSiteRedirect,
|
||||
deleteSiteRedirect,
|
||||
siteGitClone,
|
||||
siteGitPull,
|
||||
} from '../api/client'
|
||||
import { Plus, Trash2, Download, Archive, RotateCcw, Pencil, Play, Square, Redirect, GitBranch } from 'lucide-react'
|
||||
|
||||
interface Site {
|
||||
id: number
|
||||
name: string
|
||||
path: string
|
||||
status: number
|
||||
ps: string
|
||||
project_type: string
|
||||
domain_count: number
|
||||
addtime: string | null
|
||||
}
|
||||
|
||||
export function SitePage() {
|
||||
const [sites, setSites] = useState<Site[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [creatingError, setCreatingError] = useState('')
|
||||
const [backupSiteId, setBackupSiteId] = useState<number | null>(null)
|
||||
const [backups, setBackups] = useState<{ filename: string; size: number }[]>([])
|
||||
const [backupLoading, setBackupLoading] = useState(false)
|
||||
const [editSiteId, setEditSiteId] = useState<number | null>(null)
|
||||
const [editForm, setEditForm] = useState<{ domains: string; path: string; ps: string; php_version: string; force_https: boolean } | null>(null)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [editError, setEditError] = useState('')
|
||||
const [statusLoading, setStatusLoading] = useState<number | null>(null)
|
||||
const [redirectSiteId, setRedirectSiteId] = useState<number | null>(null)
|
||||
const [redirects, setRedirects] = useState<{ id: number; source: string; target: string; code: number }[]>([])
|
||||
const [redirectSource, setRedirectSource] = useState('')
|
||||
const [redirectTarget, setRedirectTarget] = useState('')
|
||||
const [redirectCode, setRedirectCode] = useState(301)
|
||||
const [redirectAdding, setRedirectAdding] = useState(false)
|
||||
const [gitSiteId, setGitSiteId] = useState<number | null>(null)
|
||||
const [gitUrl, setGitUrl] = useState('')
|
||||
const [gitBranch, setGitBranch] = useState('main')
|
||||
const [gitAction, setGitAction] = useState<'clone' | 'pull' | null>(null)
|
||||
const [gitLoading, setGitLoading] = useState(false)
|
||||
|
||||
const loadSites = () => {
|
||||
setLoading(true)
|
||||
apiRequest<Site[]>('/site/list')
|
||||
.then(setSites)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSites()
|
||||
}, [])
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
|
||||
const domainsStr = (form.elements.namedItem('domains') as HTMLInputElement).value.trim()
|
||||
const path = (form.elements.namedItem('path') as HTMLInputElement).value.trim()
|
||||
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
|
||||
|
||||
if (!name || !domainsStr) {
|
||||
setCreatingError('Name and domain(s) are required')
|
||||
return
|
||||
}
|
||||
const domains = domainsStr.split(/[\s,]+/).filter(Boolean)
|
||||
const php_version = (form.elements.namedItem('php_version') as HTMLSelectElement)?.value || '74'
|
||||
const force_https = (form.elements.namedItem('force_https') as HTMLInputElement)?.checked || false
|
||||
setCreating(true)
|
||||
setCreatingError('')
|
||||
createSite({ name, domains, path: path || undefined, ps: ps || undefined, php_version, force_https })
|
||||
.then(() => {
|
||||
setShowCreate(false)
|
||||
form.reset()
|
||||
loadSites()
|
||||
})
|
||||
.catch((err) => setCreatingError(err.message))
|
||||
.finally(() => setCreating(false))
|
||||
}
|
||||
|
||||
const handleDelete = (id: number, name: string) => {
|
||||
if (!confirm(`Delete site "${name}"? This cannot be undone.`)) return
|
||||
deleteSite(id)
|
||||
.then(loadSites)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const openBackupModal = (siteId: number) => {
|
||||
setBackupSiteId(siteId)
|
||||
setBackups([])
|
||||
listSiteBackups(siteId)
|
||||
.then((data) => setBackups(data.backups))
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleCreateBackup = () => {
|
||||
if (!backupSiteId) return
|
||||
setBackupLoading(true)
|
||||
createSiteBackup(backupSiteId)
|
||||
.then(() => listSiteBackups(backupSiteId).then((d) => setBackups(d.backups)))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setBackupLoading(false))
|
||||
}
|
||||
|
||||
const handleRestore = (filename: string) => {
|
||||
if (!backupSiteId || !confirm(`Restore from ${filename}? This will overwrite existing files.`)) return
|
||||
setBackupLoading(true)
|
||||
restoreSiteBackup(backupSiteId, filename)
|
||||
.then(() => setBackupSiteId(null))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setBackupLoading(false))
|
||||
}
|
||||
|
||||
const handleDownloadBackup = (filename: string) => {
|
||||
if (!backupSiteId) return
|
||||
downloadSiteBackup(backupSiteId, filename).catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const openEditModal = (siteId: number) => {
|
||||
setEditSiteId(siteId)
|
||||
setEditError('')
|
||||
getSite(siteId)
|
||||
.then((s) => setEditForm({
|
||||
domains: (s.domains || []).join(', '),
|
||||
path: s.path || '',
|
||||
ps: s.ps || '',
|
||||
php_version: s.php_version || '74',
|
||||
force_https: !!(s.force_https && s.force_https !== 0),
|
||||
}))
|
||||
.catch((err) => setEditError(err.message))
|
||||
}
|
||||
|
||||
const handleEdit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!editSiteId || !editForm) return
|
||||
const domains = editForm.domains.split(/[\s,]+/).filter(Boolean)
|
||||
if (domains.length === 0) {
|
||||
setEditError('At least one domain is required')
|
||||
return
|
||||
}
|
||||
setEditLoading(true)
|
||||
setEditError('')
|
||||
updateSite(editSiteId, {
|
||||
domains,
|
||||
path: editForm.path || undefined,
|
||||
ps: editForm.ps || undefined,
|
||||
php_version: editForm.php_version,
|
||||
force_https: editForm.force_https,
|
||||
})
|
||||
.then(() => {
|
||||
setEditSiteId(null)
|
||||
setEditForm(null)
|
||||
loadSites()
|
||||
})
|
||||
.catch((err) => setEditError(err.message))
|
||||
.finally(() => setEditLoading(false))
|
||||
}
|
||||
|
||||
const handleSetStatus = (siteId: number, enable: boolean) => {
|
||||
setStatusLoading(siteId)
|
||||
setSiteStatus(siteId, enable)
|
||||
.then(loadSites)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setStatusLoading(null))
|
||||
}
|
||||
|
||||
const openRedirectModal = (siteId: number) => {
|
||||
setRedirectSiteId(siteId)
|
||||
setRedirectSource('')
|
||||
setRedirectTarget('')
|
||||
setRedirectCode(301)
|
||||
listSiteRedirects(siteId)
|
||||
.then(setRedirects)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleAddRedirect = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!redirectSiteId || !redirectSource.trim() || !redirectTarget.trim()) return
|
||||
setRedirectAdding(true)
|
||||
addSiteRedirect(redirectSiteId, redirectSource.trim(), redirectTarget.trim(), redirectCode)
|
||||
.then(() => listSiteRedirects(redirectSiteId).then(setRedirects))
|
||||
.then(() => { setRedirectSource(''); setRedirectTarget('') })
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setRedirectAdding(false))
|
||||
}
|
||||
|
||||
const handleDeleteRedirect = (redirectId: number) => {
|
||||
if (!redirectSiteId) return
|
||||
deleteSiteRedirect(redirectSiteId, redirectId)
|
||||
.then(() => listSiteRedirects(redirectSiteId).then(setRedirects))
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Website</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Site
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Create Site</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
{creatingError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{creatingError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Site Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Domain(s) <span className="text-gray-500">(comma or space separated)</span>
|
||||
</label>
|
||||
<input
|
||||
name="domains"
|
||||
type="text"
|
||||
placeholder="example.com www.example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Path <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
name="path"
|
||||
type="text"
|
||||
placeholder="/www/wwwroot/example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
PHP Version
|
||||
</label>
|
||||
<select
|
||||
name="php_version"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="74">7.4</option>
|
||||
<option value="80">8.0</option>
|
||||
<option value="81">8.1</option>
|
||||
<option value="82">8.2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input name="force_https" type="checkbox" id="create_force_https" className="rounded" />
|
||||
<label htmlFor="create_force_https" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Force HTTPS (redirect HTTP to HTTPS)
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Note <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
name="ps"
|
||||
type="text"
|
||||
placeholder="My website"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Path</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Domains</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
No sites yet. Click "Add Site" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sites.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white">{s.name}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{s.path}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{s.domain_count}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{s.project_type}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
{s.status === 1 ? (
|
||||
<button
|
||||
onClick={() => handleSetStatus(s.id, false)}
|
||||
disabled={statusLoading === s.id}
|
||||
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSetStatus(s.id, true)}
|
||||
disabled={statusLoading === s.id}
|
||||
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded disabled:opacity-50"
|
||||
title="Start"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setGitSiteId(s.id); setGitAction('clone'); setGitUrl(''); setGitBranch('main') }}
|
||||
className="p-2 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded"
|
||||
title="Git Deploy"
|
||||
>
|
||||
<GitBranch className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openRedirectModal(s.id)}
|
||||
className="p-2 text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded"
|
||||
title="Redirects"
|
||||
>
|
||||
<Redirect className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(s.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openBackupModal(s.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Backup"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(s.id, s.name)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{gitSiteId && gitAction && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Git Deploy</h2>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGitAction('clone')}
|
||||
className={`px-3 py-1.5 rounded text-sm ${gitAction === 'clone' ? 'bg-emerald-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||
>
|
||||
Clone
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGitAction('pull')}
|
||||
className={`px-3 py-1.5 rounded text-sm ${gitAction === 'pull' ? 'bg-emerald-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||
>
|
||||
Pull
|
||||
</button>
|
||||
</div>
|
||||
{gitAction === 'clone' ? (
|
||||
<form onSubmit={handleGitClone} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Repository URL</label>
|
||||
<input
|
||||
value={gitUrl}
|
||||
onChange={(e) => setGitUrl(e.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Branch</label>
|
||||
<input
|
||||
value={gitBranch}
|
||||
onChange={(e) => setGitBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Site path must be empty. This will clone the repo into the site directory.</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => { setGitSiteId(null); setGitAction(null) }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">Cancel</button>
|
||||
<button type="submit" disabled={gitLoading} className="px-4 py-2 bg-emerald-600 text-white rounded-lg disabled:opacity-50">
|
||||
{gitLoading ? 'Cloning...' : 'Clone'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Pull latest changes from the remote repository.</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => { setGitSiteId(null); setGitAction(null) }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">Cancel</button>
|
||||
<button onClick={handleGitPull} disabled={gitLoading} className="px-4 py-2 bg-emerald-600 text-white rounded-lg disabled:opacity-50">
|
||||
{gitLoading ? 'Pulling...' : 'Pull'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redirectSiteId && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Redirects</h2>
|
||||
<form onSubmit={handleAddRedirect} className="space-y-2 mb-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
value={redirectSource}
|
||||
onChange={(e) => setRedirectSource(e.target.value)}
|
||||
placeholder="/old-path"
|
||||
className="flex-1 min-w-[100px] px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<span className="self-center text-gray-500">→</span>
|
||||
<input
|
||||
value={redirectTarget}
|
||||
onChange={(e) => setRedirectTarget(e.target.value)}
|
||||
placeholder="/new-path or https://..."
|
||||
className="flex-1 min-w-[100px] px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
<select
|
||||
value={redirectCode}
|
||||
onChange={(e) => setRedirectCode(Number(e.target.value))}
|
||||
className="px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
|
||||
>
|
||||
<option value={301}>301</option>
|
||||
<option value={302}>302</option>
|
||||
</select>
|
||||
<button type="submit" disabled={redirectAdding} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{redirects.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No redirects</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{redirects.map((r) => (
|
||||
<li key={r.id} className="flex items-center justify-between gap-2 text-sm py-1 border-b dark:border-gray-700">
|
||||
<span className="font-mono truncate">{r.source}</span>
|
||||
<span className="text-gray-500">→</span>
|
||||
<span className="font-mono truncate flex-1">{r.target}</span>
|
||||
<span className="text-gray-400">{r.code}</span>
|
||||
<button onClick={() => handleDeleteRedirect(r.id)} className="p-1 text-red-600 hover:bg-red-50 rounded">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={() => setRedirectSiteId(null)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editSiteId && editForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Edit Site</h2>
|
||||
<form onSubmit={handleEdit} className="space-y-4">
|
||||
{editError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Domain(s) <span className="text-gray-500">(comma or space separated)</span>
|
||||
</label>
|
||||
<input
|
||||
value={editForm.domains}
|
||||
onChange={(e) => setEditForm({ ...editForm, domains: e.target.value })}
|
||||
type="text"
|
||||
placeholder="example.com www.example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Path <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={editForm.path}
|
||||
onChange={(e) => setEditForm({ ...editForm, path: e.target.value })}
|
||||
type="text"
|
||||
placeholder="/www/wwwroot/example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">PHP Version</label>
|
||||
<select
|
||||
value={editForm.php_version}
|
||||
onChange={(e) => setEditForm({ ...editForm, php_version: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="74">7.4</option>
|
||||
<option value="80">8.0</option>
|
||||
<option value="81">8.1</option>
|
||||
<option value="82">8.2</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit_force_https"
|
||||
checked={editForm.force_https}
|
||||
onChange={(e) => setEditForm({ ...editForm, force_https: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="edit_force_https" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Force HTTPS
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Note <span className="text-gray-500">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={editForm.ps}
|
||||
onChange={(e) => setEditForm({ ...editForm, ps: e.target.value })}
|
||||
type="text"
|
||||
placeholder="My website"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditSiteId(null); setEditForm(null) }}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={editLoading}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||||
>
|
||||
{editLoading ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupSiteId && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Site Backup</h2>
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={backupLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
{backupLoading ? 'Creating...' : 'Create Backup'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Existing backups</h3>
|
||||
{backups.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No backups yet</p>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{backups.map((b) => (
|
||||
<li key={b.filename} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="truncate font-mono text-gray-700 dark:text-gray-300 flex-1">{b.filename}</span>
|
||||
<span className="text-gray-500 text-xs flex-shrink-0">{formatSize(b.size)}</span>
|
||||
<span className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleDownloadBackup(b.filename)}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRestore(b.filename)}
|
||||
disabled={backupLoading}
|
||||
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
|
||||
title="Restore"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setBackupSiteId(null)}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
YakPanel-server/frontend/src/pages/SoftPage.tsx
Normal file
133
YakPanel-server/frontend/src/pages/SoftPage.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest } from '../api/client'
|
||||
import { Check, X, Loader2, Package } from 'lucide-react'
|
||||
|
||||
interface Software {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
pkg: string
|
||||
installed: boolean
|
||||
version: string
|
||||
}
|
||||
|
||||
export function SoftPage() {
|
||||
const [software, setSoftware] = useState<Software[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [actionId, setActionId] = useState<string | null>(null)
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
apiRequest<{ software: Software[] }>('/soft/list')
|
||||
.then((data) => setSoftware(data.software || []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleInstall = (id: string) => {
|
||||
setActionId(id)
|
||||
setError('')
|
||||
apiRequest<{ status: boolean }>(`/soft/install/${id}`, { method: 'POST' })
|
||||
.then(() => load())
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
const handleUninstall = (id: string, name: string) => {
|
||||
if (!confirm(`Uninstall ${name}?`)) return
|
||||
setActionId(id)
|
||||
setError('')
|
||||
apiRequest<{ status: boolean }>(`/soft/uninstall/${id}`, { method: 'POST' })
|
||||
.then(() => load())
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setActionId(null))
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-gray-500">Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">App Store</h1>
|
||||
<button
|
||||
onClick={load}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
|
||||
Install/uninstall via apt. Panel must run with sufficient privileges. Target: Debian/Ubuntu.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{software.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-4 flex flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{s.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
{s.installed ? (
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-sm flex-shrink-0">
|
||||
<Check className="w-4 h-4" />
|
||||
{s.version || 'Installed'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-gray-500 text-sm flex-shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
Not installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto pt-3">
|
||||
{s.installed ? (
|
||||
<button
|
||||
onClick={() => handleUninstall(s.id, s.name)}
|
||||
disabled={actionId === s.id}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{actionId === s.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Uninstall'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleInstall(s.id)}
|
||||
disabled={actionId === s.id}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{actionId === s.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Install'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
YakPanel-server/frontend/src/pages/TerminalPage.tsx
Normal file
84
YakPanel-server/frontend/src/pages/TerminalPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
export function TerminalPage() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const token = localStorage.getItem('token')
|
||||
const wsUrl = `${protocol}//${host}/api/v1/terminal/ws${token ? `?token=${token}` : ''}`
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
},
|
||||
})
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(containerRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
|
||||
ws.onopen = () => {
|
||||
term.writeln('YakPanel Terminal - Connected')
|
||||
term.writeln('')
|
||||
}
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (typeof ev.data === 'string') {
|
||||
term.write(ev.data)
|
||||
} else {
|
||||
const decoder = new TextDecoder()
|
||||
term.write(decoder.decode(ev.data))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
term.writeln('\r\n\r\nDisconnected from server.')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
term.writeln('\r\n\r\nConnection error.')
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
const resize = () => fitAddon.fit()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
terminalRef.current = term
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
ws.close()
|
||||
term.dispose()
|
||||
terminalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Terminal</h1>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700"
|
||||
style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
YakPanel-server/frontend/src/pages/UsersPage.tsx
Normal file
194
YakPanel-server/frontend/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiRequest, listUsers, createUser, deleteUser, toggleUserActive } from '../api/client'
|
||||
import { Plus, Trash2, UserCheck, UserX } from 'lucide-react'
|
||||
|
||||
interface UserRecord {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
is_active: boolean
|
||||
is_superuser: boolean
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const [users, setUsers] = useState<UserRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState('')
|
||||
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
|
||||
|
||||
const loadUsers = () => {
|
||||
setLoading(true)
|
||||
listUsers()
|
||||
.then((data) => {
|
||||
setUsers(data)
|
||||
apiRequest<{ id: number }>('/auth/me').then((me) => setCurrentUserId(me.id))
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const username = (form.elements.namedItem('username') as HTMLInputElement).value.trim()
|
||||
const password = (form.elements.namedItem('password') as HTMLInputElement).value
|
||||
const email = (form.elements.namedItem('email') as HTMLInputElement).value.trim()
|
||||
|
||||
if (!username || username.length < 2) {
|
||||
setCreateError('Username must be at least 2 characters')
|
||||
return
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
setCreateError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setCreateError('')
|
||||
createUser({ username, password, email })
|
||||
.then(() => {
|
||||
setShowCreate(false)
|
||||
form.reset()
|
||||
loadUsers()
|
||||
})
|
||||
.catch((err) => setCreateError(err.message))
|
||||
.finally(() => setCreating(false))
|
||||
}
|
||||
|
||||
const handleDelete = (id: number, username: string) => {
|
||||
if (!confirm(`Delete user "${username}"?`)) return
|
||||
deleteUser(id)
|
||||
.then(loadUsers)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
const handleToggleActive = (id: number) => {
|
||||
toggleUserActive(id)
|
||||
.then(loadUsers)
|
||||
.catch((err) => setError(err.message))
|
||||
}
|
||||
|
||||
if (loading && users.length === 0) return <div className="text-gray-500">Loading...</div>
|
||||
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Users</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Username</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Email</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Role</th>
|
||||
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-white">{u.username}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{u.email || '-'}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">
|
||||
<span className={u.is_active ? 'text-green-600' : 'text-gray-500'}>{u.is_active ? 'Active' : 'Inactive'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{u.is_superuser ? 'Admin' : 'User'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className="flex gap-1 justify-end">
|
||||
{u.id !== currentUserId && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleToggleActive(u.id)}
|
||||
className={`p-2 rounded ${u.is_active ? 'text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20' : 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'}`}
|
||||
title={u.is_active ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{u.is_active ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.username)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add User</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
{createError && (
|
||||
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">{createError}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="newuser"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
minLength={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email (optional)</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="user@example.com"
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={creating} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
YakPanel-server/frontend/src/vite-env.d.ts
vendored
Normal file
1
YakPanel-server/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user