Initial YakPanel commit

This commit is contained in:
Niranjan
2026-04-07 02:04:22 +05:30
commit 2826d3e7f3
5359 changed files with 1390724 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*.iml
.idea/
idea/*
.vscode/*

14
NOTICE Normal file
View 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
View 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">
[![BTWAF](https://img.shields.io/badge/YakPanel-YakPanel-blue)](https://github.com/YakPanel/YakPanel)
[![social](https://img.shields.io/github/stars/YakPanel/YakPanel?style=social)](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
Demohttps://demo.yakpanel.com/fdgi87jbn/<br/>
username: yakpanel<br/>
password: yakpanel
<!-- ![image](https://github.com/YakPanel/YakPanel/assets/31841517/c40d68f5-1cbb-4117-ab47-b52b14228cce) -->
![image](https://www.yakpanel.com/static/new/images/index/home.png)
## 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
View File

@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `1249648969@qq.com`

472
Yak-Panel Normal file
View 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 port0.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
View 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
View 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 checkouts `docker-compose.yml`.
**Post-install (all methods):** change the default `admin` password, restrict firewall to SSH + panel port, add TLS (e.g. Lets 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

View 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

View 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"]

View File

@@ -0,0 +1 @@
# YakPanel - Backend Application

View File

@@ -0,0 +1 @@
# YakPanel - API routes

View 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"}

View 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}

View 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"}

View 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"}

View 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),
},
}

View 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"}

View 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"}

View 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"}

View 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)}

View 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}

View 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())}

View 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),
}

View 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"}

View 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"}

View 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()

View 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"}

View 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"}

View 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"}

View 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"])}

View 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())

View 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}

View File

@@ -0,0 +1 @@
# YakPanel - Core module

View 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)

View 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)

View 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)

View 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

View 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 = {"&quot;": '"', "&quot": '"', "&#x27;": "'", "&#x27": "'"}
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)

View 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"}

View 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"]

View 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)

View 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="")

View 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)

View 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)

View 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="")

View 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)

View 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)

View 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

View 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)

View 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)

View File

@@ -0,0 +1 @@
# YakPanel - Services

View 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

View 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"

View 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"

View 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"}

View File

@@ -0,0 +1,4 @@
# YakPanel - Celery tasks
from app.tasks.celery_app import celery_app
__all__ = ["celery_app"]

View 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,
)

View 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)}

View 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*"]

View 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

View 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)

View 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())

View 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:

View 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

View 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>

View 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;
}
}

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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

View 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
}

View 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' })
}

View 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>
)
}

View 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 },
]

View 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;
}

View 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>,
)

View 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>
)
}

View 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>
)
}

View 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 &quot;Apply to System&quot; 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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&apos;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&apos;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>
)
}

View 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>
)
}

View 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 &quot;Apply to UFW&quot; 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 &quot;Add Process&quot; 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

Some files were not shown because too many files have changed in this diff Show More