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