new changes

This commit is contained in:
Niranjan
2026-04-07 09:46:22 +05:30
parent b679cc3bb5
commit 5e86cc7e40
36 changed files with 1077 additions and 208 deletions

View File

@@ -1,8 +1,13 @@
"""YakPanel - File manager API""" """YakPanel - File manager API"""
import os import os
import shutil
import stat
import zipfile
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel, Field
from app.core.config import get_runtime_config from app.core.config import get_runtime_config
from app.core.utils import read_file, write_file, path_safe_check from app.core.utils import read_file, write_file, path_safe_check
@@ -56,6 +61,40 @@ def _resolve_path(path: str) -> str:
return full return full
def _stat_entry(path: str, name: str) -> dict | None:
item_path = os.path.join(path, name)
try:
st = os.stat(item_path, follow_symlinks=False)
except OSError:
return None
is_dir = os.path.isdir(item_path)
owner = str(st.st_uid)
group = str(st.st_gid)
try:
import pwd
import grp
owner = pwd.getpwuid(st.st_uid).pw_name
group = grp.getgrgid(st.st_gid).gr_name
except (ImportError, KeyError, OSError):
pass
try:
sym = stat.filemode(st.st_mode)
except Exception:
sym = ""
return {
"name": name,
"is_dir": is_dir,
"size": st.st_size if not is_dir else 0,
"mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
"mtime_ts": int(st.st_mtime),
"mode": format(st.st_mode & 0o777, "o"),
"mode_symbolic": sym,
"owner": owner,
"group": group,
}
@router.get("/list") @router.get("/list")
async def files_list( async def files_list(
path: str = "/", path: str = "/",
@@ -77,21 +116,94 @@ async def files_list(
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
except OSError as e: except OSError as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
for name in names: for name in sorted(names, key=str.lower):
item_path = os.path.join(full, name) row = _stat_entry(full, name)
try: if row:
stat = os.stat(item_path) items.append(row)
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
display_path = full if full == "/" else full.rstrip("/") display_path = full if full == "/" else full.rstrip("/")
return {"path": display_path, "items": items} return {"path": display_path, "items": items}
@router.get("/dir-size")
async def files_dir_size(
path: str,
current_user: User = Depends(get_current_user),
):
"""Return total byte size of directory tree (may be slow on large trees)."""
try:
full = _resolve_path(path)
except HTTPException:
raise
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Not a directory")
total = 0
try:
for root, _dirs, files in os.walk(full):
for fn in files:
fp = os.path.join(root, fn)
try:
total += os.path.getsize(fp)
except OSError:
pass
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
return {"size": total}
@router.get("/search")
async def files_search(
q: str,
path: str = "/",
max_results: int = 200,
current_user: User = Depends(get_current_user),
):
"""Find files/folders whose name contains q (case-insensitive), walking from path."""
if not q or not q.strip():
return {"path": path, "results": []}
try:
root = _resolve_path(path)
except HTTPException:
raise
if not os.path.isdir(root):
raise HTTPException(status_code=400, detail="Not a directory")
qn = q.strip().lower()
results: list[dict] = []
skip_prefixes = tuple()
if os.name != "nt" and root in ("/", "//"):
skip_prefixes = ("/proc", "/sys", "/dev")
def should_skip(p: str) -> bool:
ap = os.path.abspath(p)
return any(ap == sp or ap.startswith(sp + os.sep) for sp in skip_prefixes)
try:
for dirpath, dirnames, filenames in os.walk(root, topdown=True):
if len(results) >= max_results:
break
if should_skip(dirpath):
dirnames[:] = []
continue
try:
for dn in list(dirnames):
if len(results) >= max_results:
break
if qn in dn.lower():
rel = os.path.join(dirpath, dn)
results.append({"path": rel.replace("\\", "/"), "name": dn, "is_dir": True})
for fn in filenames:
if len(results) >= max_results:
break
if qn in fn.lower():
rel = os.path.join(dirpath, fn)
results.append({"path": rel.replace("\\", "/"), "name": fn, "is_dir": False})
except OSError:
continue
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
return {"path": path, "query": q, "results": results[:max_results]}
@router.get("/read") @router.get("/read")
async def files_read( async def files_read(
path: str, path: str,
@@ -265,3 +377,211 @@ async def files_write(
if not write_file(full, body.content): if not write_file(full, body.content):
raise HTTPException(status_code=500, detail="Failed to write file") raise HTTPException(status_code=500, detail="Failed to write file")
return {"status": True, "msg": "Saved"} return {"status": True, "msg": "Saved"}
class TouchRequest(BaseModel):
path: str
name: str
class ChmodRequest(BaseModel):
file_path: str
mode: str = Field(description="Octal mode e.g. 0644 or 755")
recursive: bool = False
class CopyRequest(BaseModel):
path: str
name: str
dest_path: str
dest_name: str | None = None
class MoveRequest(BaseModel):
path: str
name: str
dest_path: str
dest_name: str | None = None
class CompressRequest(BaseModel):
path: str
names: list[str]
archive_name: str
@router.post("/touch")
async def files_touch(
body: TouchRequest,
current_user: User = Depends(get_current_user),
):
"""Create an empty file in directory path."""
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")
if not path_safe_check(body.name):
raise HTTPException(status_code=400, detail="Invalid name")
full = os.path.join(parent, body.name)
if os.path.exists(full):
raise HTTPException(status_code=400, detail="Already exists")
try:
open(full, "a", encoding="utf-8").close()
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Created"}
@router.post("/chmod")
async def files_chmod(
body: ChmodRequest,
current_user: User = Depends(get_current_user),
):
"""chmod a file or directory. mode is octal string (644, 0755)."""
try:
full = _resolve_path(body.file_path)
except HTTPException:
raise
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
try:
mode = int(body.mode.strip(), 8)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid mode")
if mode < 0 or mode > 0o7777:
raise HTTPException(status_code=400, detail="Invalid mode")
def _chmod_one(p: str) -> None:
os.chmod(p, mode)
try:
if body.recursive and os.path.isdir(full):
for root, dirs, files in os.walk(full):
for d in dirs:
_chmod_one(os.path.join(root, d))
for f in files:
_chmod_one(os.path.join(root, f))
_chmod_one(full)
else:
_chmod_one(full)
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Permissions updated"}
@router.post("/copy")
async def files_copy(
body: CopyRequest,
current_user: User = Depends(get_current_user),
):
"""Copy file or directory into dest_path (optionally new name)."""
try:
src_parent = _resolve_path(body.path)
dest_parent = _resolve_path(body.dest_path)
except HTTPException:
raise
if not body.name or ".." in body.name:
raise HTTPException(status_code=400, detail="Invalid name")
src = os.path.join(src_parent, body.name)
dest_name = body.dest_name or body.name
if ".." in dest_name or "/" in dest_name or "\\" in dest_name:
raise HTTPException(status_code=400, detail="Invalid destination name")
dest = os.path.join(dest_parent, dest_name)
if not os.path.exists(src):
raise HTTPException(status_code=404, detail="Source not found")
if os.path.exists(dest):
raise HTTPException(status_code=400, detail="Destination already exists")
if not os.path.isdir(dest_parent):
raise HTTPException(status_code=400, detail="Destination parent must be a directory")
try:
if os.path.isdir(src):
shutil.copytree(src, dest, symlinks=True)
else:
shutil.copy2(src, dest)
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Copied"}
@router.post("/move")
async def files_move(
body: MoveRequest,
current_user: User = Depends(get_current_user),
):
"""Move (rename) file or directory to another folder."""
try:
src_parent = _resolve_path(body.path)
dest_parent = _resolve_path(body.dest_path)
except HTTPException:
raise
if not body.name or ".." in body.name:
raise HTTPException(status_code=400, detail="Invalid name")
src = os.path.join(src_parent, body.name)
dest_name = body.dest_name or body.name
if ".." in dest_name or "/" in dest_name or "\\" in dest_name:
raise HTTPException(status_code=400, detail="Invalid destination name")
dest = os.path.join(dest_parent, dest_name)
if not os.path.exists(src):
raise HTTPException(status_code=404, detail="Source not found")
if os.path.exists(dest):
raise HTTPException(status_code=400, detail="Destination already exists")
if not os.path.isdir(dest_parent):
raise HTTPException(status_code=400, detail="Destination parent must be a directory")
try:
shutil.move(src, dest)
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Moved"}
@router.post("/compress")
async def files_compress(
body: CompressRequest,
current_user: User = Depends(get_current_user),
):
"""Create a zip in path containing named files/folders."""
try:
parent = _resolve_path(body.path)
except HTTPException:
raise
if not os.path.isdir(parent):
raise HTTPException(status_code=400, detail="Not a directory")
if not body.names:
raise HTTPException(status_code=400, detail="Nothing to compress")
name = (body.archive_name or "archive").strip()
if not name.lower().endswith(".zip"):
name += ".zip"
if ".." in name or "/" in name or "\\" in name:
raise HTTPException(status_code=400, detail="Invalid archive name")
zip_path = os.path.join(parent, name)
if os.path.exists(zip_path):
raise HTTPException(status_code=400, detail="Archive already exists")
try:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for entry in body.names:
if not entry or ".." in entry or "/" in entry or "\\" in entry:
continue
src = os.path.join(parent, entry)
if not os.path.exists(src):
continue
if os.path.isfile(src):
zf.write(src, arcname=entry.replace("\\", "/"))
elif os.path.isdir(src):
for root, _dirs, files in os.walk(src):
for f in files:
fp = os.path.join(root, f)
zn = os.path.relpath(fp, parent).replace("\\", "/")
zf.write(fp, zn)
except OSError as e:
if os.path.exists(zip_path):
try:
os.remove(zip_path)
except OSError:
pass
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Compressed", "archive": name}

View File

@@ -1 +1 @@
import{j as t}from"./index-cE9w-Kq7.js";function o({variant:l="danger",children:r,className:a="",dismissible:e,onDismiss:s}){return t.jsxs("div",{className:`alert alert-${l} ${e?"alert-dismissible fade show":""} ${a}`.trim(),role:"alert",children:[r,e?t.jsx("button",{type:"button",className:"btn-close","aria-label":"Close",onClick:s}):null]})}export{o as A}; import{j as t}from"./index-CRR9sQ49.js";function o({variant:l="danger",children:r,className:a="",dismissible:e,onDismiss:s}){return t.jsxs("div",{className:`alert alert-${l} ${e?"alert-dismissible fade show":""} ${a}`.trim(),role:"alert",children:[r,e?t.jsx("button",{type:"button",className:"btn-close","aria-label":"Close",onClick:s}):null]})}export{o as A};

View File

@@ -1 +1 @@
import{j as a}from"./index-cE9w-Kq7.js";function i({children:n,variant:r="primary",size:t,type:o="button",disabled:m,className:s="",...u}){return a.jsx("button",{type:o,disabled:m,className:`btn btn-${r}${t?` btn-${t}`:""} ${s}`.trim(),...u,children:n})}export{i as A}; import{j as a}from"./index-CRR9sQ49.js";function i({children:n,variant:r="primary",size:t,type:o="button",disabled:m,className:s="",...u}){return a.jsx("button",{type:o,disabled:m,className:`btn btn-${r}${t?` btn-${t}`:""} ${s}`.trim(),...u,children:n})}export{i as A};

View File

@@ -1 +1 @@
import{j as e}from"./index-cE9w-Kq7.js";function c({title:s,iconClass:a,children:i,headerExtra:r,className:d="",bodyClassName:l=""}){return e.jsxs("div",{className:`card flex-fill ${d}`.trim(),children:[(s||r)&&e.jsxs("div",{className:"card-header border-0 pb-0 d-flex align-items-center justify-content-between flex-wrap gap-2",children:[e.jsxs("h4",{className:"mb-0 d-flex align-items-center gap-2",children:[a?e.jsx("i",{className:a,"aria-hidden":!0}):null,s]}),r]}),e.jsx("div",{className:`card-body ${l}`.trim(),children:i})]})}export{c as A}; import{j as e}from"./index-CRR9sQ49.js";function c({title:s,iconClass:a,children:i,headerExtra:r,className:d="",bodyClassName:l=""}){return e.jsxs("div",{className:`card flex-fill ${d}`.trim(),children:[(s||r)&&e.jsxs("div",{className:"card-header border-0 pb-0 d-flex align-items-center justify-content-between flex-wrap gap-2",children:[e.jsxs("h4",{className:"mb-0 d-flex align-items-center gap-2",children:[a?e.jsx("i",{className:a,"aria-hidden":!0}):null,s]}),r]}),e.jsx("div",{className:`card-body ${l}`.trim(),children:i})]})}export{c as A};

View File

@@ -1 +1 @@
import{j as t}from"./index-cE9w-Kq7.js";function l({children:r,className:s="",responsive:a=!0}){const e=t.jsx("table",{className:`table table-hover ${s}`.trim(),children:r});return a?t.jsx("div",{className:"table-responsive",children:e}):e}export{l as A}; import{j as t}from"./index-CRR9sQ49.js";function l({children:r,className:s="",responsive:a=!0}){const e=t.jsx("table",{className:`table table-hover ${s}`.trim(),children:r});return a?t.jsx("div",{className:"table-responsive",children:e}):e}export{l as A};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{r as i,g as m,j as s}from"./index-cE9w-Kq7.js";import{A as n}from"./AdminAlert-yrdXFH0e.js";import{A as o}from"./AdminCard-BvdkSQBp.js";import{P as d}from"./PageHeader-HdM4gpcn.js";function p(){const[e,r]=i.useState(null),[a,l]=i.useState("");return i.useEffect(()=>{m().then(r).catch(c=>l(c.message))},[]),a?s.jsxs(s.Fragment,{children:[s.jsx(d,{}),s.jsx(n,{variant:"danger",children:a})]}):e?s.jsxs(s.Fragment,{children:[s.jsx(d,{}),s.jsxs("div",{className:"row g-3 mb-4",children:[s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-world",title:"Websites",value:e.site_count})}),s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-folder-share",title:"FTP Accounts",value:e.ftp_count})}),s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-database",title:"Databases",value:e.database_count})})]}),s.jsxs("div",{className:"row g-3",children:[s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-cpu",title:"CPU",value:`${e.system.cpu_percent}%`})}),s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-device-desktop",title:"Memory",value:`${e.system.memory_percent}%`,subtitle:`${e.system.memory_used_mb} / ${e.system.memory_total_mb} MB`})}),s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-database-export",title:"Disk",value:`${e.system.disk_percent}%`,subtitle:`${e.system.disk_used_gb} / ${e.system.disk_total_gb} GB`})})]})]}):s.jsxs(s.Fragment,{children:[s.jsx(d,{}),s.jsx("div",{className:"placeholder-glow",children:s.jsx("span",{className:"placeholder col-12 rounded",style:{height:"8rem"}})})]})}function t({iconClass:e,title:r,value:a,subtitle:l}){return s.jsxs(o,{className:"border-0 shadow-sm",bodyClassName:"d-flex align-items-center gap-3",children:[s.jsx("span",{className:"avatar avatar-md bg-primary-transparent text-primary rounded-circle d-flex align-items-center justify-content-center flex-shrink-0",children:s.jsx("i",{className:`${e} fs-4`,"aria-hidden":!0})}),s.jsxs("div",{children:[s.jsx("p",{className:"text-muted mb-0 small",children:r}),s.jsx("p",{className:"fs-4 fw-semibold mb-0",children:a}),l?s.jsx("p",{className:"text-muted small mb-0",children:l}):null]})]})}export{p as DashboardPage}; import{r as i,g as m,j as s}from"./index-CRR9sQ49.js";import{A as n}from"./AdminAlert-DW1IRWce.js";import{A as o}from"./AdminCard-DNA70pGd.js";import{P as d}from"./PageHeader-BcjNf7GG.js";function p(){const[e,r]=i.useState(null),[a,l]=i.useState("");return i.useEffect(()=>{m().then(r).catch(c=>l(c.message))},[]),a?s.jsxs(s.Fragment,{children:[s.jsx(d,{}),s.jsx(n,{variant:"danger",children:a})]}):e?s.jsxs(s.Fragment,{children:[s.jsx(d,{}),s.jsxs("div",{className:"row g-3 mb-4",children:[s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-world",title:"Websites",value:e.site_count})}),s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-folder-share",title:"FTP Accounts",value:e.ftp_count})}),s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-database",title:"Databases",value:e.database_count})})]}),s.jsxs("div",{className:"row g-3",children:[s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-cpu",title:"CPU",value:`${e.system.cpu_percent}%`})}),s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-device-desktop",title:"Memory",value:`${e.system.memory_percent}%`,subtitle:`${e.system.memory_used_mb} / ${e.system.memory_total_mb} MB`})}),s.jsx("div",{className:"col-md-4 d-flex",children:s.jsx(t,{iconClass:"ti ti-database-export",title:"Disk",value:`${e.system.disk_percent}%`,subtitle:`${e.system.disk_used_gb} / ${e.system.disk_total_gb} GB`})})]})]}):s.jsxs(s.Fragment,{children:[s.jsx(d,{}),s.jsx("div",{className:"placeholder-glow",children:s.jsx("span",{className:"placeholder col-12 rounded",style:{height:"8rem"}})})]})}function t({iconClass:e,title:r,value:a,subtitle:l}){return s.jsxs(o,{className:"border-0 shadow-sm",bodyClassName:"d-flex align-items-center gap-3",children:[s.jsx("span",{className:"avatar avatar-md bg-primary-transparent text-primary rounded-circle d-flex align-items-center justify-content-center flex-shrink-0",children:s.jsx("i",{className:`${e} fs-4`,"aria-hidden":!0})}),s.jsxs("div",{children:[s.jsx("p",{className:"text-muted mb-0 small",children:r}),s.jsx("p",{className:"fs-4 fw-semibold mb-0",children:a}),l?s.jsx("p",{className:"text-muted small mb-0",children:l}):null]})]})}export{p as DashboardPage};

View File

@@ -1 +1 @@
import{r as t,j as e,a as o}from"./index-cE9w-Kq7.js";import{M as l}from"./Modal-CL3xZqxR.js";import{A as R}from"./AdminAlert-yrdXFH0e.js";import{A as m}from"./AdminButton-ByutG8m-.js";import{P as N}from"./PageHeader-HdM4gpcn.js";function k(){const[d,y]=t.useState([]),[c,b]=t.useState([]),[v,u]=t.useState(!0),[x,h]=t.useState(""),[i,p]=t.useState(null),[a,n]=t.useState(null),[j,f]=t.useState(""),g=()=>{u(!0),Promise.all([o("/ssl/domains"),o("/ssl/certificates")]).then(([s,r])=>{y(s),b(r.certificates||[])}).catch(s=>h(s.message)).finally(()=>u(!1))};t.useEffect(()=>{g()},[]);const S=s=>{s.preventDefault(),a&&(p(a.name),o("/ssl/request",{method:"POST",body:JSON.stringify({domain:a.name,webroot:a.site_path,email:j})}).then(()=>{n(null),g()}).catch(r=>h(r.message)).finally(()=>p(null)))},q=s=>c.some(r=>r.name===s||r.name.startsWith(s+" "));return v?e.jsxs(e.Fragment,{children:[e.jsx(N,{title:"Domains & SSL"}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(N,{title:"Domains & SSL"}),x?e.jsx(R,{className:"mb-3",children:x}):null,e.jsx("div",{className:"alert alert-warning small mb-4",children:"Request Let's Encrypt certificates for your site domains. Requires certbot and nginx configured for the domain."}),e.jsxs("div",{className:"row g-4",children:[e.jsx("div",{className:"col-lg-6",children:e.jsxs("div",{className:"card h-100",children:[e.jsx("div",{className:"card-header",children:"Domains (from sites)"}),e.jsx("div",{className:"list-group list-group-flush overflow-auto",style:{maxHeight:"20rem"},children:d.length===0?e.jsx("div",{className:"list-group-item text-secondary text-center py-4",children:"No domains. Add a site first."}):d.map(s=>e.jsxs("div",{className:"list-group-item d-flex align-items-center justify-content-between gap-2 flex-wrap",children:[e.jsxs("div",{className:"small",children:[e.jsx("span",{className:"font-monospace",children:s.name}),s.port!=="80"?e.jsxs("span",{className:"text-secondary ms-1",children:[":",s.port]}):null,e.jsxs("span",{className:"text-secondary ms-2",children:["(",s.site_name,")"]})]}),e.jsx("div",{children:q(s.name)?e.jsxs("span",{className:"text-success small",children:[e.jsx("i",{className:"ti ti-shield-check me-1","aria-hidden":!0}),"Cert"]}):e.jsx(m,{variant:"outline-primary",size:"sm",disabled:!!i,onClick:()=>{n(s),f("")},children:i===s.name?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):"Request SSL"})})]},s.id))})]})}),e.jsx("div",{className:"col-lg-6",children:e.jsxs("div",{className:"card h-100",children:[e.jsx("div",{className:"card-header",children:"Certificates"}),e.jsx("div",{className:"list-group list-group-flush overflow-auto",style:{maxHeight:"20rem"},children:c.length===0?e.jsx("div",{className:"list-group-item text-secondary text-center py-4",children:"No certificates yet"}):c.map(s=>e.jsxs("div",{className:"list-group-item d-flex align-items-center gap-2",children:[e.jsx("i",{className:"ti ti-shield-check text-success flex-shrink-0","aria-hidden":!0}),e.jsx("span",{className:"font-monospace small text-break",children:s.name})]},s.name))})]})})]}),e.jsxs(l,{show:!!a,onHide:()=>n(null),centered:!0,children:[e.jsx(l.Header,{closeButton:!0,children:e.jsxs(l.Title,{children:["Request SSL for ",a==null?void 0:a.name]})}),a?e.jsxs("form",{onSubmit:S,children:[e.jsxs(l.Body,{children:[e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Domain"}),e.jsx("input",{type:"text",value:a.name,readOnly:!0,className:"form-control-plaintext border rounded px-3 py-2 bg-body-secondary"})]}),e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Webroot (site path)"}),e.jsx("input",{type:"text",value:a.site_path,readOnly:!0,className:"form-control-plaintext border rounded px-3 py-2 bg-body-secondary"})]}),e.jsxs("div",{className:"mb-0",children:[e.jsx("label",{className:"form-label",children:"Email (for Let's Encrypt)"}),e.jsx("input",{type:"email",value:j,onChange:s=>f(s.target.value),placeholder:"admin@example.com",className:"form-control",required:!0})]})]}),e.jsxs(l.Footer,{children:[e.jsx(m,{type:"button",variant:"secondary",onClick:()=>n(null),children:"Cancel"}),e.jsx(m,{type:"submit",variant:"primary",disabled:!!i,children:i?"Requesting…":"Request"})]})]}):null]})]})}export{k as DomainsPage}; import{r as t,j as e,a as o}from"./index-CRR9sQ49.js";import{M as l}from"./Modal-B7V4w_St.js";import{A as R}from"./AdminAlert-DW1IRWce.js";import{A as m}from"./AdminButton-Bd2cLTu3.js";import{P as N}from"./PageHeader-BcjNf7GG.js";function k(){const[d,y]=t.useState([]),[c,b]=t.useState([]),[v,u]=t.useState(!0),[x,h]=t.useState(""),[i,p]=t.useState(null),[a,n]=t.useState(null),[j,f]=t.useState(""),g=()=>{u(!0),Promise.all([o("/ssl/domains"),o("/ssl/certificates")]).then(([s,r])=>{y(s),b(r.certificates||[])}).catch(s=>h(s.message)).finally(()=>u(!1))};t.useEffect(()=>{g()},[]);const S=s=>{s.preventDefault(),a&&(p(a.name),o("/ssl/request",{method:"POST",body:JSON.stringify({domain:a.name,webroot:a.site_path,email:j})}).then(()=>{n(null),g()}).catch(r=>h(r.message)).finally(()=>p(null)))},q=s=>c.some(r=>r.name===s||r.name.startsWith(s+" "));return v?e.jsxs(e.Fragment,{children:[e.jsx(N,{title:"Domains & SSL"}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(N,{title:"Domains & SSL"}),x?e.jsx(R,{className:"mb-3",children:x}):null,e.jsx("div",{className:"alert alert-warning small mb-4",children:"Request Let's Encrypt certificates for your site domains. Requires certbot and nginx configured for the domain."}),e.jsxs("div",{className:"row g-4",children:[e.jsx("div",{className:"col-lg-6",children:e.jsxs("div",{className:"card h-100",children:[e.jsx("div",{className:"card-header",children:"Domains (from sites)"}),e.jsx("div",{className:"list-group list-group-flush overflow-auto",style:{maxHeight:"20rem"},children:d.length===0?e.jsx("div",{className:"list-group-item text-secondary text-center py-4",children:"No domains. Add a site first."}):d.map(s=>e.jsxs("div",{className:"list-group-item d-flex align-items-center justify-content-between gap-2 flex-wrap",children:[e.jsxs("div",{className:"small",children:[e.jsx("span",{className:"font-monospace",children:s.name}),s.port!=="80"?e.jsxs("span",{className:"text-secondary ms-1",children:[":",s.port]}):null,e.jsxs("span",{className:"text-secondary ms-2",children:["(",s.site_name,")"]})]}),e.jsx("div",{children:q(s.name)?e.jsxs("span",{className:"text-success small",children:[e.jsx("i",{className:"ti ti-shield-check me-1","aria-hidden":!0}),"Cert"]}):e.jsx(m,{variant:"outline-primary",size:"sm",disabled:!!i,onClick:()=>{n(s),f("")},children:i===s.name?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):"Request SSL"})})]},s.id))})]})}),e.jsx("div",{className:"col-lg-6",children:e.jsxs("div",{className:"card h-100",children:[e.jsx("div",{className:"card-header",children:"Certificates"}),e.jsx("div",{className:"list-group list-group-flush overflow-auto",style:{maxHeight:"20rem"},children:c.length===0?e.jsx("div",{className:"list-group-item text-secondary text-center py-4",children:"No certificates yet"}):c.map(s=>e.jsxs("div",{className:"list-group-item d-flex align-items-center gap-2",children:[e.jsx("i",{className:"ti ti-shield-check text-success flex-shrink-0","aria-hidden":!0}),e.jsx("span",{className:"font-monospace small text-break",children:s.name})]},s.name))})]})})]}),e.jsxs(l,{show:!!a,onHide:()=>n(null),centered:!0,children:[e.jsx(l.Header,{closeButton:!0,children:e.jsxs(l.Title,{children:["Request SSL for ",a==null?void 0:a.name]})}),a?e.jsxs("form",{onSubmit:S,children:[e.jsxs(l.Body,{children:[e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Domain"}),e.jsx("input",{type:"text",value:a.name,readOnly:!0,className:"form-control-plaintext border rounded px-3 py-2 bg-body-secondary"})]}),e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Webroot (site path)"}),e.jsx("input",{type:"text",value:a.site_path,readOnly:!0,className:"form-control-plaintext border rounded px-3 py-2 bg-body-secondary"})]}),e.jsxs("div",{className:"mb-0",children:[e.jsx("label",{className:"form-label",children:"Email (for Let's Encrypt)"}),e.jsx("input",{type:"email",value:j,onChange:s=>f(s.target.value),placeholder:"admin@example.com",className:"form-control",required:!0})]})]}),e.jsxs(l.Footer,{children:[e.jsx(m,{type:"button",variant:"secondary",onClick:()=>n(null),children:"Cancel"}),e.jsx(m,{type:"submit",variant:"primary",disabled:!!i,children:i?"Requesting…":"Request"})]})]}):null]})]})}export{k as DomainsPage};

View File

@@ -1 +1 @@
import{j as t}from"./index-cE9w-Kq7.js";function m({iconClass:s="ti ti-inbox",title:a,description:e,action:i}){return t.jsxs("div",{className:"text-center py-5 text-muted",children:[t.jsx("i",{className:`${s} display-4 d-block mb-3`,"aria-hidden":!0}),t.jsx("h5",{className:"text-body",children:a}),e?t.jsx("p",{className:"mb-3",children:e}):null,i]})}export{m as E}; import{j as t}from"./index-CRR9sQ49.js";function m({iconClass:s="ti ti-inbox",title:a,description:e,action:i}){return t.jsxs("div",{className:"text-center py-5 text-muted",children:[t.jsx("i",{className:`${s} display-4 d-block mb-3`,"aria-hidden":!0}),t.jsx("h5",{className:"text-body",children:a}),e?t.jsx("p",{className:"mb-3",children:e}):null,i]})}export{m as E};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{r as t,j as e,a as p,N as k}from"./index-cE9w-Kq7.js";import{M as r}from"./Modal-CL3xZqxR.js";import{A as y}from"./AdminAlert-yrdXFH0e.js";import{A as c}from"./AdminButton-ByutG8m-.js";import{A as D}from"./AdminTable-eCi7S__-.js";import{E as H}from"./EmptyState-CmnFWkSO.js";import{P as g}from"./PageHeader-HdM4gpcn.js";function W(){const[o,v]=t.useState([]),[A,x]=t.useState(!0),[u,d]=t.useState(""),[w,a]=t.useState(!1),[j,f]=t.useState(!1),[N,m]=t.useState(""),[h,b]=t.useState(!1),n=()=>{x(!0),p("/firewall/list").then(v).catch(s=>d(s.message)).finally(()=>x(!1))};t.useEffect(()=>{n()},[]);const S=s=>{s.preventDefault();const l=s.currentTarget,i=l.elements.namedItem("port").value.trim(),E=l.elements.namedItem("protocol").value,F=l.elements.namedItem("action").value,R=l.elements.namedItem("ps").value.trim();if(!i){m("Port is required");return}f(!0),m(""),p("/firewall/create",{method:"POST",body:JSON.stringify({port:i,protocol:E,action:F,ps:R})}).then(()=>{a(!1),l.reset(),n()}).catch(T=>m(T.message)).finally(()=>f(!1))},C=(s,l)=>{confirm(`Delete rule for port ${l}?`)&&p(`/firewall/${s}`,{method:"DELETE"}).then(n).catch(i=>d(i.message))},P=()=>{b(!0),k().then(()=>n()).catch(s=>d(s.message)).finally(()=>b(!1))};return A?e.jsxs(e.Fragment,{children:[e.jsx(g,{title:"Security / Firewall"}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(g,{title:"Security / Firewall",actions:e.jsxs("div",{className:"d-flex flex-wrap gap-2",children:[e.jsxs(c,{variant:"success",disabled:h||o.length===0,onClick:P,children:[h?e.jsx("span",{className:"spinner-border spinner-border-sm me-1",role:"status"}):e.jsx("i",{className:"ti ti-bolt me-1","aria-hidden":!0}),h?"Applying…":"Apply to UFW"]}),e.jsxs(c,{variant:"primary",onClick:()=>a(!0),children:[e.jsx("i",{className:"ti ti-plus me-1","aria-hidden":!0}),"Add Rule"]})]})}),u?e.jsx(y,{className:"mb-3",children:u}):null,e.jsxs("div",{className:"alert alert-warning small mb-3",children:['Rules are stored in the panel. Click "Apply to UFW" to run ',e.jsx("code",{className:"font-monospace",children:"ufw allow/deny"})," for each rule."]}),e.jsxs(r,{show:w,onHide:()=>a(!1),centered:!0,children:[e.jsx(r.Header,{closeButton:!0,children:e.jsx(r.Title,{children:"Add Firewall Rule"})}),e.jsxs("form",{onSubmit:S,children:[e.jsxs(r.Body,{children:[N?e.jsx(y,{className:"mb-3",children:N}):null,e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Port"}),e.jsx("input",{name:"port",type:"text",placeholder:"80 or 80-90 or 80,443",className:"form-control",required:!0})]}),e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Protocol"}),e.jsxs("select",{name:"protocol",className:"form-select",children:[e.jsx("option",{value:"tcp",children:"TCP"}),e.jsx("option",{value:"udp",children:"UDP"})]})]}),e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Action"}),e.jsxs("select",{name:"action",className:"form-select",children:[e.jsx("option",{value:"accept",children:"Accept"}),e.jsx("option",{value:"drop",children:"Drop"}),e.jsx("option",{value:"reject",children:"Reject"})]})]}),e.jsxs("div",{className:"mb-0",children:[e.jsx("label",{className:"form-label",children:"Note (optional)"}),e.jsx("input",{name:"ps",type:"text",placeholder:"HTTP",className:"form-control"})]})]}),e.jsxs(r.Footer,{children:[e.jsx(c,{type:"button",variant:"secondary",onClick:()=>a(!1),children:"Cancel"}),e.jsx(c,{type:"submit",variant:"primary",disabled:j,children:j?"Adding…":"Add"})]})]})]}),e.jsx("div",{className:"card",children:e.jsxs(D,{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Port"}),e.jsx("th",{children:"Protocol"}),e.jsx("th",{children:"Action"}),e.jsx("th",{children:"Note"}),e.jsx("th",{className:"text-end",children:"Actions"})]})}),e.jsx("tbody",{children:o.length===0?e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"p-0",children:e.jsx(H,{title:"No rules",description:'Click "Add Rule" to create one.'})})}):o.map(s=>e.jsxs("tr",{children:[e.jsx("td",{className:"font-monospace",children:s.port}),e.jsx("td",{children:s.protocol}),e.jsx("td",{children:s.action}),e.jsx("td",{children:s.ps||"—"}),e.jsx("td",{className:"text-end",children:e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-danger p-1",title:"Delete",onClick:()=>C(s.id,s.port),children:e.jsx("i",{className:"ti ti-trash","aria-hidden":!0})})})]},s.id))})]})})]})}export{W as FirewallPage}; import{r as t,j as e,a as p,V as k}from"./index-CRR9sQ49.js";import{M as r}from"./Modal-B7V4w_St.js";import{A as y}from"./AdminAlert-DW1IRWce.js";import{A as c}from"./AdminButton-Bd2cLTu3.js";import{A as D}from"./AdminTable-BLiLxfnS.js";import{E as H}from"./EmptyState-C61VdEFl.js";import{P as g}from"./PageHeader-BcjNf7GG.js";function W(){const[o,v]=t.useState([]),[A,x]=t.useState(!0),[u,d]=t.useState(""),[w,a]=t.useState(!1),[j,f]=t.useState(!1),[N,m]=t.useState(""),[h,b]=t.useState(!1),n=()=>{x(!0),p("/firewall/list").then(v).catch(s=>d(s.message)).finally(()=>x(!1))};t.useEffect(()=>{n()},[]);const S=s=>{s.preventDefault();const l=s.currentTarget,i=l.elements.namedItem("port").value.trim(),E=l.elements.namedItem("protocol").value,F=l.elements.namedItem("action").value,R=l.elements.namedItem("ps").value.trim();if(!i){m("Port is required");return}f(!0),m(""),p("/firewall/create",{method:"POST",body:JSON.stringify({port:i,protocol:E,action:F,ps:R})}).then(()=>{a(!1),l.reset(),n()}).catch(T=>m(T.message)).finally(()=>f(!1))},C=(s,l)=>{confirm(`Delete rule for port ${l}?`)&&p(`/firewall/${s}`,{method:"DELETE"}).then(n).catch(i=>d(i.message))},P=()=>{b(!0),k().then(()=>n()).catch(s=>d(s.message)).finally(()=>b(!1))};return A?e.jsxs(e.Fragment,{children:[e.jsx(g,{title:"Security / Firewall"}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(g,{title:"Security / Firewall",actions:e.jsxs("div",{className:"d-flex flex-wrap gap-2",children:[e.jsxs(c,{variant:"success",disabled:h||o.length===0,onClick:P,children:[h?e.jsx("span",{className:"spinner-border spinner-border-sm me-1",role:"status"}):e.jsx("i",{className:"ti ti-bolt me-1","aria-hidden":!0}),h?"Applying…":"Apply to UFW"]}),e.jsxs(c,{variant:"primary",onClick:()=>a(!0),children:[e.jsx("i",{className:"ti ti-plus me-1","aria-hidden":!0}),"Add Rule"]})]})}),u?e.jsx(y,{className:"mb-3",children:u}):null,e.jsxs("div",{className:"alert alert-warning small mb-3",children:['Rules are stored in the panel. Click "Apply to UFW" to run ',e.jsx("code",{className:"font-monospace",children:"ufw allow/deny"})," for each rule."]}),e.jsxs(r,{show:w,onHide:()=>a(!1),centered:!0,children:[e.jsx(r.Header,{closeButton:!0,children:e.jsx(r.Title,{children:"Add Firewall Rule"})}),e.jsxs("form",{onSubmit:S,children:[e.jsxs(r.Body,{children:[N?e.jsx(y,{className:"mb-3",children:N}):null,e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Port"}),e.jsx("input",{name:"port",type:"text",placeholder:"80 or 80-90 or 80,443",className:"form-control",required:!0})]}),e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Protocol"}),e.jsxs("select",{name:"protocol",className:"form-select",children:[e.jsx("option",{value:"tcp",children:"TCP"}),e.jsx("option",{value:"udp",children:"UDP"})]})]}),e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Action"}),e.jsxs("select",{name:"action",className:"form-select",children:[e.jsx("option",{value:"accept",children:"Accept"}),e.jsx("option",{value:"drop",children:"Drop"}),e.jsx("option",{value:"reject",children:"Reject"})]})]}),e.jsxs("div",{className:"mb-0",children:[e.jsx("label",{className:"form-label",children:"Note (optional)"}),e.jsx("input",{name:"ps",type:"text",placeholder:"HTTP",className:"form-control"})]})]}),e.jsxs(r.Footer,{children:[e.jsx(c,{type:"button",variant:"secondary",onClick:()=>a(!1),children:"Cancel"}),e.jsx(c,{type:"submit",variant:"primary",disabled:j,children:j?"Adding…":"Add"})]})]})]}),e.jsx("div",{className:"card",children:e.jsxs(D,{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Port"}),e.jsx("th",{children:"Protocol"}),e.jsx("th",{children:"Action"}),e.jsx("th",{children:"Note"}),e.jsx("th",{className:"text-end",children:"Actions"})]})}),e.jsx("tbody",{children:o.length===0?e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"p-0",children:e.jsx(H,{title:"No rules",description:'Click "Add Rule" to create one.'})})}):o.map(s=>e.jsxs("tr",{children:[e.jsx("td",{className:"font-monospace",children:s.port}),e.jsx("td",{children:s.protocol}),e.jsx("td",{children:s.action}),e.jsx("td",{children:s.ps||"—"}),e.jsx("td",{className:"text-end",children:e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-danger p-1",title:"Delete",onClick:()=>C(s.id,s.port),children:e.jsx("i",{className:"ti ti-trash","aria-hidden":!0})})})]},s.id))})]})})]})}export{W as FirewallPage};

View File

@@ -1 +1 @@
import{r as t,L as p,j as e,M as C}from"./index-cE9w-Kq7.js";import{A as P}from"./AdminAlert-yrdXFH0e.js";import{A as g}from"./AdminButton-ByutG8m-.js";import{P as E}from"./PageHeader-HdM4gpcn.js";function _(a){return a<1024?a+" B":a<1024*1024?(a/1024).toFixed(1)+" KB":(a/1024/1024).toFixed(1)+" MB"}function $(){const[a,j]=t.useState("/"),[m,N]=t.useState([]),[v,x]=t.useState(!0),[h,i]=t.useState(""),[l,y]=t.useState(null),[b,u]=t.useState(""),[o,r]=t.useState(!1),[c,w]=t.useState(500),d=s=>{x(!0),i(""),C(s).then(n=>{j(n.path),N(n.items.sort((f,F)=>f.is_dir===F.is_dir?0:f.is_dir?-1:1))}).catch(n=>i(n.message)).finally(()=>x(!1))};t.useEffect(()=>{d(a)},[]),t.useEffect(()=>{l&&(r(!0),p(l,c).then(s=>u(s.content)).catch(s=>i(s.message)).finally(()=>r(!1)))},[l,c]);const k=s=>{if(s.is_dir){const n=a==="/"?"/"+s.name:a+"/"+s.name;d(n)}else y(a==="/"?s.name:a+"/"+s.name)},L=()=>{const s=a.replace(/\/$/,"").split("/").filter(Boolean);if(s.length<=1)return;s.pop();const n=s.length===0?"/":"/"+s.join("/");d(n)},S=()=>{l&&(r(!0),p(l,c).then(s=>u(s.content)).catch(s=>i(s.message)).finally(()=>r(!1)))},B=a.split("/").filter(Boolean).length>0;return e.jsxs(e.Fragment,{children:[e.jsx(E,{title:"Logs"}),e.jsxs("div",{className:"d-flex flex-wrap align-items-center gap-2 mb-3",children:[e.jsxs(g,{variant:"secondary",size:"sm",onClick:L,disabled:!B,children:[e.jsx("i",{className:"ti ti-arrow-left me-1","aria-hidden":!0}),"Back"]}),e.jsxs("code",{className:"small bg-body-secondary px-2 py-1 rounded text-break",children:["Path: ",a||"/"]})]}),h?e.jsx(P,{className:"mb-3",children:h}):null,e.jsxs("div",{className:"row g-3",children:[e.jsx("div",{className:"col-lg-6",children:e.jsxs("div",{className:"card h-100",children:[e.jsx("div",{className:"card-header small fw-medium",children:"Log files"}),v?e.jsx("div",{className:"card-body text-center py-5",children:e.jsx("span",{className:"spinner-border text-secondary",role:"status"})}):e.jsx("div",{className:"list-group list-group-flush overflow-auto",style:{maxHeight:500},children:m.length===0?e.jsx("div",{className:"list-group-item text-secondary text-center py-4",children:"Empty directory"}):m.map(s=>e.jsxs("button",{type:"button",className:"list-group-item list-group-item-action d-flex gap-2 align-items-center",onClick:()=>k(s),children:[e.jsx("i",{className:`ti flex-shrink-0 ${s.is_dir?"ti-folder text-warning":"ti-file text-secondary"}`,"aria-hidden":!0}),e.jsx("span",{className:"text-truncate",children:s.name}),s.is_dir?null:e.jsx("span",{className:"small text-secondary ms-auto flex-shrink-0",children:_(s.size)})]},s.name))})]})}),e.jsx("div",{className:"col-lg-6",children:e.jsxs("div",{className:"card h-100 d-flex flex-column",style:{minHeight:400},children:[e.jsxs("div",{className:"card-header d-flex align-items-center justify-content-between gap-2 flex-wrap",children:[e.jsx("span",{className:"small fw-medium text-truncate",children:l||"Select a log file"}),l?e.jsxs("div",{className:"d-flex align-items-center gap-2 flex-shrink-0",children:[e.jsx("label",{className:"small text-secondary mb-0",children:"Lines:"}),e.jsxs("select",{value:c,onChange:s=>w(Number(s.target.value)),className:"form-select form-select-sm",style:{width:"auto"},children:[e.jsx("option",{value:100,children:"100"}),e.jsx("option",{value:500,children:"500"}),e.jsx("option",{value:1e3,children:"1000"}),e.jsx("option",{value:5e3,children:"5000"}),e.jsx("option",{value:1e4,children:"10000"})]}),e.jsx(g,{variant:"light",size:"sm",onClick:S,disabled:o,title:"Refresh",children:o?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):e.jsx("i",{className:"ti ti-refresh","aria-hidden":!0})})]}):null]}),e.jsx("div",{className:"card-body flex-grow-1 overflow-auto",children:l?o?e.jsx("div",{className:"text-center py-5",children:e.jsx("span",{className:"spinner-border text-secondary",role:"status"})}):e.jsx("pre",{className:"font-monospace small mb-0 text-break",style:{whiteSpace:"pre-wrap"},children:b||"(empty)"}):e.jsx("p",{className:"text-secondary small mb-0",children:"Click a log file to view"})})]})})]})]})}export{$ as LogsPage}; import{r as t,T as p,j as e,U as C}from"./index-CRR9sQ49.js";import{A as P}from"./AdminAlert-DW1IRWce.js";import{A as g}from"./AdminButton-Bd2cLTu3.js";import{P as E}from"./PageHeader-BcjNf7GG.js";function _(a){return a<1024?a+" B":a<1024*1024?(a/1024).toFixed(1)+" KB":(a/1024/1024).toFixed(1)+" MB"}function $(){const[a,j]=t.useState("/"),[m,N]=t.useState([]),[v,x]=t.useState(!0),[h,i]=t.useState(""),[l,y]=t.useState(null),[b,u]=t.useState(""),[o,r]=t.useState(!1),[c,w]=t.useState(500),d=s=>{x(!0),i(""),C(s).then(n=>{j(n.path),N(n.items.sort((f,F)=>f.is_dir===F.is_dir?0:f.is_dir?-1:1))}).catch(n=>i(n.message)).finally(()=>x(!1))};t.useEffect(()=>{d(a)},[]),t.useEffect(()=>{l&&(r(!0),p(l,c).then(s=>u(s.content)).catch(s=>i(s.message)).finally(()=>r(!1)))},[l,c]);const k=s=>{if(s.is_dir){const n=a==="/"?"/"+s.name:a+"/"+s.name;d(n)}else y(a==="/"?s.name:a+"/"+s.name)},L=()=>{const s=a.replace(/\/$/,"").split("/").filter(Boolean);if(s.length<=1)return;s.pop();const n=s.length===0?"/":"/"+s.join("/");d(n)},S=()=>{l&&(r(!0),p(l,c).then(s=>u(s.content)).catch(s=>i(s.message)).finally(()=>r(!1)))},B=a.split("/").filter(Boolean).length>0;return e.jsxs(e.Fragment,{children:[e.jsx(E,{title:"Logs"}),e.jsxs("div",{className:"d-flex flex-wrap align-items-center gap-2 mb-3",children:[e.jsxs(g,{variant:"secondary",size:"sm",onClick:L,disabled:!B,children:[e.jsx("i",{className:"ti ti-arrow-left me-1","aria-hidden":!0}),"Back"]}),e.jsxs("code",{className:"small bg-body-secondary px-2 py-1 rounded text-break",children:["Path: ",a||"/"]})]}),h?e.jsx(P,{className:"mb-3",children:h}):null,e.jsxs("div",{className:"row g-3",children:[e.jsx("div",{className:"col-lg-6",children:e.jsxs("div",{className:"card h-100",children:[e.jsx("div",{className:"card-header small fw-medium",children:"Log files"}),v?e.jsx("div",{className:"card-body text-center py-5",children:e.jsx("span",{className:"spinner-border text-secondary",role:"status"})}):e.jsx("div",{className:"list-group list-group-flush overflow-auto",style:{maxHeight:500},children:m.length===0?e.jsx("div",{className:"list-group-item text-secondary text-center py-4",children:"Empty directory"}):m.map(s=>e.jsxs("button",{type:"button",className:"list-group-item list-group-item-action d-flex gap-2 align-items-center",onClick:()=>k(s),children:[e.jsx("i",{className:`ti flex-shrink-0 ${s.is_dir?"ti-folder text-warning":"ti-file text-secondary"}`,"aria-hidden":!0}),e.jsx("span",{className:"text-truncate",children:s.name}),s.is_dir?null:e.jsx("span",{className:"small text-secondary ms-auto flex-shrink-0",children:_(s.size)})]},s.name))})]})}),e.jsx("div",{className:"col-lg-6",children:e.jsxs("div",{className:"card h-100 d-flex flex-column",style:{minHeight:400},children:[e.jsxs("div",{className:"card-header d-flex align-items-center justify-content-between gap-2 flex-wrap",children:[e.jsx("span",{className:"small fw-medium text-truncate",children:l||"Select a log file"}),l?e.jsxs("div",{className:"d-flex align-items-center gap-2 flex-shrink-0",children:[e.jsx("label",{className:"small text-secondary mb-0",children:"Lines:"}),e.jsxs("select",{value:c,onChange:s=>w(Number(s.target.value)),className:"form-select form-select-sm",style:{width:"auto"},children:[e.jsx("option",{value:100,children:"100"}),e.jsx("option",{value:500,children:"500"}),e.jsx("option",{value:1e3,children:"1000"}),e.jsx("option",{value:5e3,children:"5000"}),e.jsx("option",{value:1e4,children:"10000"})]}),e.jsx(g,{variant:"light",size:"sm",onClick:S,disabled:o,title:"Refresh",children:o?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):e.jsx("i",{className:"ti ti-refresh","aria-hidden":!0})})]}):null]}),e.jsx("div",{className:"card-body flex-grow-1 overflow-auto",children:l?o?e.jsx("div",{className:"text-center py-5",children:e.jsx("span",{className:"spinner-border text-secondary",role:"status"})}):e.jsx("pre",{className:"font-monospace small mb-0 text-break",style:{whiteSpace:"pre-wrap"},children:b||"(empty)"}):e.jsx("p",{className:"text-secondary small mb-0",children:"Click a log file to view"})})]})})]})]})}export{$ as LogsPage};

View File

@@ -1 +1 @@
import{r,j as s,a as b,G as g,H as v}from"./index-cE9w-Kq7.js";import{A as N}from"./AdminAlert-yrdXFH0e.js";import{A as y}from"./AdminTable-eCi7S__-.js";import{P as o}from"./PageHeader-HdM4gpcn.js";function M(){const[e,d]=r.useState(null),[c,l]=r.useState([]),[t,n]=r.useState(null),[i,p]=r.useState("");return r.useEffect(()=>{const a=()=>{b("/monitor/system").then(d).catch(m=>p(m.message))},h=()=>{g(50).then(m=>l(m.processes)).catch(()=>l([]))},j=()=>{v().then(n).catch(()=>n(null))};a(),h(),j();const u=setInterval(()=>{a(),h(),j()},3e3);return()=>clearInterval(u)},[]),i&&!e?s.jsxs(s.Fragment,{children:[s.jsx(o,{title:"Monitor"}),s.jsx(N,{children:i})]}):e?s.jsxs(s.Fragment,{children:[s.jsx(o,{title:"Monitor"}),i?s.jsx(N,{className:"mb-3",children:i}):null,s.jsx("p",{className:"small text-secondary mb-3",children:"Refreshes every 3 seconds"}),s.jsxs("div",{className:"row g-3 mb-3",children:[s.jsx("div",{className:"col-md-4",children:s.jsx(x,{iconClass:"ti ti-cpu",title:"CPU",value:`${e.cpu_percent}%`,subtitle:"Usage",percent:e.cpu_percent})}),s.jsx("div",{className:"col-md-4",children:s.jsx(x,{iconClass:"ti ti-device-sd-card",title:"Memory",value:`${e.memory_used_mb} / ${e.memory_total_mb} MB`,subtitle:`${e.memory_percent}% used`,percent:e.memory_percent})}),s.jsx("div",{className:"col-md-4",children:s.jsx(x,{iconClass:"ti ti-database",title:"Disk",value:`${e.disk_used_gb} / ${e.disk_total_gb} GB`,subtitle:`${e.disk_percent}% used`,percent:e.disk_percent})})]}),t?s.jsx("div",{className:"card mb-3",children:s.jsxs("div",{className:"card-body",children:[s.jsxs("div",{className:"d-flex align-items-center gap-2 mb-3",children:[s.jsx("i",{className:"ti ti-network fs-5","aria-hidden":!0}),s.jsx("span",{className:"fw-medium",children:"Network I/O"})]}),s.jsxs("div",{className:"row g-3 small",children:[s.jsxs("div",{className:"col-6",children:[s.jsx("span",{className:"text-secondary d-block",children:"Sent"}),s.jsxs("span",{className:"font-monospace fw-medium",children:[t.bytes_sent_mb," MB"]})]}),s.jsxs("div",{className:"col-6",children:[s.jsx("span",{className:"text-secondary d-block",children:"Received"}),s.jsxs("span",{className:"font-monospace fw-medium",children:[t.bytes_recv_mb," MB"]})]})]})]})}):null,s.jsxs("div",{className:"card",children:[s.jsxs("div",{className:"card-header d-flex align-items-center gap-2",children:[s.jsx("i",{className:"ti ti-cpu","aria-hidden":!0}),s.jsx("span",{className:"fw-medium",children:"Top Processes (by CPU)"})]}),s.jsx("div",{className:"table-responsive",style:{maxHeight:"20rem"},children:s.jsxs(y,{responsive:!1,children:[s.jsx("thead",{className:"sticky-top bg-body-secondary",children:s.jsxs("tr",{children:[s.jsx("th",{className:"small",children:"PID"}),s.jsx("th",{className:"small",children:"Name"}),s.jsx("th",{className:"small",children:"User"}),s.jsx("th",{className:"small text-end",children:"CPU %"}),s.jsx("th",{className:"small text-end",children:"Mem %"}),s.jsx("th",{className:"small",children:"Status"})]})}),s.jsx("tbody",{children:c.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:6,className:"text-center text-secondary small py-3",children:"No process data"})}):c.map(a=>s.jsxs("tr",{className:"small",children:[s.jsx("td",{className:"font-monospace",children:a.pid}),s.jsx("td",{className:"text-truncate",style:{maxWidth:120},title:a.name,children:a.name}),s.jsx("td",{children:a.username}),s.jsxs("td",{className:"text-end font-monospace",children:[a.cpu_percent,"%"]}),s.jsxs("td",{className:"text-end font-monospace",children:[a.memory_percent,"%"]}),s.jsx("td",{className:"text-secondary",children:a.status})]},a.pid))})]})})]}),s.jsxs("div",{className:"alert alert-warning small mt-3 mb-0",children:[s.jsxs("div",{className:"d-flex align-items-center gap-2 fw-medium mb-1",children:[s.jsx("i",{className:"ti ti-activity","aria-hidden":!0}),"Live monitoring"]}),"System metrics, processes, and network stats are polled every 3 seconds."]})]}):s.jsxs(s.Fragment,{children:[s.jsx(o,{title:"Monitor"}),s.jsx("p",{className:"text-secondary",children:"Loading…"})]})}function x({iconClass:e,title:d,value:c,subtitle:l,percent:t}){const n=t>90?"bg-danger":t>70?"bg-warning":"bg-primary";return s.jsx("div",{className:"card h-100",children:s.jsxs("div",{className:"card-body",children:[s.jsxs("div",{className:"d-flex align-items-center gap-3 mb-3",children:[s.jsx("div",{className:"p-3 rounded bg-primary-subtle text-primary",children:s.jsx("i",{className:`${e} fs-2`,"aria-hidden":!0})}),s.jsxs("div",{children:[s.jsx("p",{className:"small text-secondary mb-0",children:d}),s.jsx("p",{className:"h5 mb-0",children:c}),s.jsx("p",{className:"small text-secondary mb-0",children:l})]})]}),s.jsx("div",{className:"progress",style:{height:6},children:s.jsx("div",{className:`progress-bar ${n}`,role:"progressbar",style:{width:`${Math.min(t,100)}%`}})})]})})}export{M as MonitorPage}; import{r,j as s,a as b,O as g,P as v}from"./index-CRR9sQ49.js";import{A as N}from"./AdminAlert-DW1IRWce.js";import{A as y}from"./AdminTable-BLiLxfnS.js";import{P as o}from"./PageHeader-BcjNf7GG.js";function M(){const[e,d]=r.useState(null),[c,l]=r.useState([]),[t,n]=r.useState(null),[i,p]=r.useState("");return r.useEffect(()=>{const a=()=>{b("/monitor/system").then(d).catch(m=>p(m.message))},h=()=>{g(50).then(m=>l(m.processes)).catch(()=>l([]))},j=()=>{v().then(n).catch(()=>n(null))};a(),h(),j();const u=setInterval(()=>{a(),h(),j()},3e3);return()=>clearInterval(u)},[]),i&&!e?s.jsxs(s.Fragment,{children:[s.jsx(o,{title:"Monitor"}),s.jsx(N,{children:i})]}):e?s.jsxs(s.Fragment,{children:[s.jsx(o,{title:"Monitor"}),i?s.jsx(N,{className:"mb-3",children:i}):null,s.jsx("p",{className:"small text-secondary mb-3",children:"Refreshes every 3 seconds"}),s.jsxs("div",{className:"row g-3 mb-3",children:[s.jsx("div",{className:"col-md-4",children:s.jsx(x,{iconClass:"ti ti-cpu",title:"CPU",value:`${e.cpu_percent}%`,subtitle:"Usage",percent:e.cpu_percent})}),s.jsx("div",{className:"col-md-4",children:s.jsx(x,{iconClass:"ti ti-device-sd-card",title:"Memory",value:`${e.memory_used_mb} / ${e.memory_total_mb} MB`,subtitle:`${e.memory_percent}% used`,percent:e.memory_percent})}),s.jsx("div",{className:"col-md-4",children:s.jsx(x,{iconClass:"ti ti-database",title:"Disk",value:`${e.disk_used_gb} / ${e.disk_total_gb} GB`,subtitle:`${e.disk_percent}% used`,percent:e.disk_percent})})]}),t?s.jsx("div",{className:"card mb-3",children:s.jsxs("div",{className:"card-body",children:[s.jsxs("div",{className:"d-flex align-items-center gap-2 mb-3",children:[s.jsx("i",{className:"ti ti-network fs-5","aria-hidden":!0}),s.jsx("span",{className:"fw-medium",children:"Network I/O"})]}),s.jsxs("div",{className:"row g-3 small",children:[s.jsxs("div",{className:"col-6",children:[s.jsx("span",{className:"text-secondary d-block",children:"Sent"}),s.jsxs("span",{className:"font-monospace fw-medium",children:[t.bytes_sent_mb," MB"]})]}),s.jsxs("div",{className:"col-6",children:[s.jsx("span",{className:"text-secondary d-block",children:"Received"}),s.jsxs("span",{className:"font-monospace fw-medium",children:[t.bytes_recv_mb," MB"]})]})]})]})}):null,s.jsxs("div",{className:"card",children:[s.jsxs("div",{className:"card-header d-flex align-items-center gap-2",children:[s.jsx("i",{className:"ti ti-cpu","aria-hidden":!0}),s.jsx("span",{className:"fw-medium",children:"Top Processes (by CPU)"})]}),s.jsx("div",{className:"table-responsive",style:{maxHeight:"20rem"},children:s.jsxs(y,{responsive:!1,children:[s.jsx("thead",{className:"sticky-top bg-body-secondary",children:s.jsxs("tr",{children:[s.jsx("th",{className:"small",children:"PID"}),s.jsx("th",{className:"small",children:"Name"}),s.jsx("th",{className:"small",children:"User"}),s.jsx("th",{className:"small text-end",children:"CPU %"}),s.jsx("th",{className:"small text-end",children:"Mem %"}),s.jsx("th",{className:"small",children:"Status"})]})}),s.jsx("tbody",{children:c.length===0?s.jsx("tr",{children:s.jsx("td",{colSpan:6,className:"text-center text-secondary small py-3",children:"No process data"})}):c.map(a=>s.jsxs("tr",{className:"small",children:[s.jsx("td",{className:"font-monospace",children:a.pid}),s.jsx("td",{className:"text-truncate",style:{maxWidth:120},title:a.name,children:a.name}),s.jsx("td",{children:a.username}),s.jsxs("td",{className:"text-end font-monospace",children:[a.cpu_percent,"%"]}),s.jsxs("td",{className:"text-end font-monospace",children:[a.memory_percent,"%"]}),s.jsx("td",{className:"text-secondary",children:a.status})]},a.pid))})]})})]}),s.jsxs("div",{className:"alert alert-warning small mt-3 mb-0",children:[s.jsxs("div",{className:"d-flex align-items-center gap-2 fw-medium mb-1",children:[s.jsx("i",{className:"ti ti-activity","aria-hidden":!0}),"Live monitoring"]}),"System metrics, processes, and network stats are polled every 3 seconds."]})]}):s.jsxs(s.Fragment,{children:[s.jsx(o,{title:"Monitor"}),s.jsx("p",{className:"text-secondary",children:"Loading…"})]})}function x({iconClass:e,title:d,value:c,subtitle:l,percent:t}){const n=t>90?"bg-danger":t>70?"bg-warning":"bg-primary";return s.jsx("div",{className:"card h-100",children:s.jsxs("div",{className:"card-body",children:[s.jsxs("div",{className:"d-flex align-items-center gap-3 mb-3",children:[s.jsx("div",{className:"p-3 rounded bg-primary-subtle text-primary",children:s.jsx("i",{className:`${e} fs-2`,"aria-hidden":!0})}),s.jsxs("div",{children:[s.jsx("p",{className:"small text-secondary mb-0",children:d}),s.jsx("p",{className:"h5 mb-0",children:c}),s.jsx("p",{className:"small text-secondary mb-0",children:l})]})]}),s.jsx("div",{className:"progress",style:{height:6},children:s.jsx("div",{className:`progress-bar ${n}`,role:"progressbar",style:{width:`${Math.min(t,100)}%`}})})]})})}export{M as MonitorPage};

View File

@@ -1 +1 @@
import{an as c,j as e,ao as m}from"./index-cE9w-Kq7.js";const d={"/":"Dashboard","/site":"Website","/ftp":"FTP","/database":"Databases","/docker":"Docker","/control":"Monitor","/firewall":"Security","/files":"Files","/node":"Node","/logs":"Logs","/ssl_domain":"Domains","/xterm":"Terminal","/crontab":"Cron","/soft":"App Store","/config":"Settings","/services":"Services","/plugins":"Plugins","/backup-plans":"Backup Plans","/users":"Users","/login":"Login","/install":"Remote install"};function b(t){return d[t]||"YakPanel"}function p({title:t,breadcrumbs:i,actions:n}){const{pathname:o}=c(),r=t??b(o),s=i??[{label:"Home",path:"/"},{label:r}];return e.jsx("div",{className:"page-header mb-4",children:e.jsxs("div",{className:"row align-items-center",children:[e.jsxs("div",{className:"col-md-6",children:[e.jsx("h3",{className:"page-title",children:r}),e.jsx("nav",{"aria-label":"breadcrumb",children:e.jsx("ol",{className:"breadcrumb mb-0",children:s.map((a,l)=>e.jsx("li",{className:`breadcrumb-item${l===s.length-1?" active":""}`,...l===s.length-1?{"aria-current":"page"}:{},children:a.path&&l<s.length-1?e.jsx(m,{to:a.path,children:a.label}):a.label},`${a.label}-${l}`))})})]}),n?e.jsx("div",{className:"col-md-6 d-flex justify-content-md-end mt-2 mt-md-0",children:n}):null]})})}export{p as P}; import{av as c,j as e,aw as m}from"./index-CRR9sQ49.js";const d={"/":"Dashboard","/site":"Website","/ftp":"FTP","/database":"Databases","/docker":"Docker","/control":"Monitor","/firewall":"Security","/files":"Files","/node":"Node","/logs":"Logs","/ssl_domain":"Domains","/xterm":"Terminal","/crontab":"Cron","/soft":"App Store","/config":"Settings","/services":"Services","/plugins":"Plugins","/backup-plans":"Backup Plans","/users":"Users","/login":"Login","/install":"Remote install"};function b(t){return d[t]||"YakPanel"}function p({title:t,breadcrumbs:i,actions:n}){const{pathname:o}=c(),r=t??b(o),s=i??[{label:"Home",path:"/"},{label:r}];return e.jsx("div",{className:"page-header mb-4",children:e.jsxs("div",{className:"row align-items-center",children:[e.jsxs("div",{className:"col-md-6",children:[e.jsx("h3",{className:"page-title",children:r}),e.jsx("nav",{"aria-label":"breadcrumb",children:e.jsx("ol",{className:"breadcrumb mb-0",children:s.map((a,l)=>e.jsx("li",{className:`breadcrumb-item${l===s.length-1?" active":""}`,...l===s.length-1?{"aria-current":"page"}:{},children:a.path&&l<s.length-1?e.jsx(m,{to:a.path,children:a.label}):a.label},`${a.label}-${l}`))})})]}),n?e.jsx("div",{className:"col-md-6 d-flex justify-content-md-end mt-2 mt-md-0",children:n}):null]})})}export{p as P};

View File

@@ -1 +0,0 @@
import{r as a,j as e,a as R,T as U,U as w}from"./index-cE9w-Kq7.js";import{M as t}from"./Modal-CL3xZqxR.js";import{A as f}from"./AdminAlert-yrdXFH0e.js";import{A as r}from"./AdminButton-ByutG8m-.js";import{P as p}from"./PageHeader-HdM4gpcn.js";function F(){const[b,N]=a.useState([]),[v,c]=a.useState(!0),[o,m]=a.useState(""),[y,l]=a.useState(!1),[u,h]=a.useState(""),[x,j]=a.useState(!1),[g,i]=a.useState(""),d=()=>R("/plugin/list").then(s=>N(s.plugins||[])).catch(s=>m(s.message));a.useEffect(()=>{c(!0),d().finally(()=>c(!1))},[]);const A=s=>{s.preventDefault();const n=u.trim();n&&(j(!0),i(""),U(n).then(()=>{l(!1),h(""),d()}).catch(S=>i(S.message)).finally(()=>j(!1)))},P=s=>{confirm("Remove this plugin?")&&w(s).then(d).catch(n=>m(n.message))};return v?e.jsxs(e.Fragment,{children:[e.jsx(p,{title:"Plugins",actions:e.jsxs(r,{variant:"primary",disabled:!0,children:[e.jsx("i",{className:"ti ti-plus me-1","aria-hidden":!0}),"Add from URL"]})}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(p,{title:"Plugins",actions:e.jsxs(r,{variant:"primary",onClick:()=>l(!0),children:[e.jsx("i",{className:"ti ti-plus me-1","aria-hidden":!0}),"Add from URL"]})}),o?e.jsx(f,{className:"mb-3",children:o}):null,e.jsxs("div",{className:"alert alert-secondary small mb-4",children:["Built-in extensions and third-party plugins. Add plugins from a JSON manifest URL (must include ",e.jsx("code",{children:"id"}),","," ",e.jsx("code",{children:"name"}),", and optionally ",e.jsx("code",{children:"version"}),", ",e.jsx("code",{children:"desc"}),")."]}),e.jsxs(t,{show:y,onHide:()=>{l(!1),i("")},centered:!0,children:[e.jsx(t.Header,{closeButton:!0,children:e.jsx(t.Title,{children:"Add Plugin from URL"})}),e.jsxs("form",{onSubmit:A,children:[e.jsxs(t.Body,{children:[g?e.jsx(f,{className:"mb-3",children:g}):null,e.jsx("label",{className:"form-label",children:"Manifest URL"}),e.jsx("input",{value:u,onChange:s=>h(s.target.value),placeholder:"https://example.com/plugin.json",className:"form-control",required:!0})]}),e.jsxs(t.Footer,{children:[e.jsx(r,{type:"button",variant:"secondary",onClick:()=>{l(!1),i("")},children:"Cancel"}),e.jsx(r,{type:"submit",variant:"primary",disabled:x,children:x?"Adding…":"Add"})]})]})]}),e.jsx("div",{className:"row g-3",children:b.map(s=>e.jsx("div",{className:"col-md-6 col-xl-4",children:e.jsx("div",{className:"card h-100",children:e.jsxs("div",{className:"card-body d-flex gap-3",children:[e.jsx("i",{className:"ti ti-puzzle text-primary fs-2 flex-shrink-0","aria-hidden":!0}),e.jsxs("div",{className:"min-w-0 flex-grow-1",children:[e.jsxs("div",{className:"d-flex flex-wrap align-items-center gap-2 mb-1",children:[e.jsx("h3",{className:"h6 mb-0",children:s.name}),s.enabled?e.jsxs("span",{className:"badge bg-success-subtle text-success small",children:[e.jsx("i",{className:"ti ti-check me-1","aria-hidden":!0}),"Enabled"]}):null,s.builtin?null:e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-danger p-0 ms-auto",title:"Remove",onClick:()=>P(s.id),children:e.jsx("i",{className:"ti ti-trash","aria-hidden":!0})})]}),e.jsx("p",{className:"small text-secondary mb-1",children:s.desc}),e.jsxs("p",{className:"small text-muted mb-0",children:["v",s.version,s.builtin?" (built-in)":""]})]})]})})},s.id))})]})}export{F as PluginsPage};

View File

@@ -0,0 +1 @@
import{r as a,j as e,a as R,$ as U,a0 as w}from"./index-CRR9sQ49.js";import{M as t}from"./Modal-B7V4w_St.js";import{A as f}from"./AdminAlert-DW1IRWce.js";import{A as r}from"./AdminButton-Bd2cLTu3.js";import{P as p}from"./PageHeader-BcjNf7GG.js";function F(){const[b,N]=a.useState([]),[v,c]=a.useState(!0),[o,m]=a.useState(""),[y,l]=a.useState(!1),[u,h]=a.useState(""),[x,j]=a.useState(!1),[g,i]=a.useState(""),d=()=>R("/plugin/list").then(s=>N(s.plugins||[])).catch(s=>m(s.message));a.useEffect(()=>{c(!0),d().finally(()=>c(!1))},[]);const A=s=>{s.preventDefault();const n=u.trim();n&&(j(!0),i(""),U(n).then(()=>{l(!1),h(""),d()}).catch(S=>i(S.message)).finally(()=>j(!1)))},P=s=>{confirm("Remove this plugin?")&&w(s).then(d).catch(n=>m(n.message))};return v?e.jsxs(e.Fragment,{children:[e.jsx(p,{title:"Plugins",actions:e.jsxs(r,{variant:"primary",disabled:!0,children:[e.jsx("i",{className:"ti ti-plus me-1","aria-hidden":!0}),"Add from URL"]})}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(p,{title:"Plugins",actions:e.jsxs(r,{variant:"primary",onClick:()=>l(!0),children:[e.jsx("i",{className:"ti ti-plus me-1","aria-hidden":!0}),"Add from URL"]})}),o?e.jsx(f,{className:"mb-3",children:o}):null,e.jsxs("div",{className:"alert alert-secondary small mb-4",children:["Built-in extensions and third-party plugins. Add plugins from a JSON manifest URL (must include ",e.jsx("code",{children:"id"}),","," ",e.jsx("code",{children:"name"}),", and optionally ",e.jsx("code",{children:"version"}),", ",e.jsx("code",{children:"desc"}),")."]}),e.jsxs(t,{show:y,onHide:()=>{l(!1),i("")},centered:!0,children:[e.jsx(t.Header,{closeButton:!0,children:e.jsx(t.Title,{children:"Add Plugin from URL"})}),e.jsxs("form",{onSubmit:A,children:[e.jsxs(t.Body,{children:[g?e.jsx(f,{className:"mb-3",children:g}):null,e.jsx("label",{className:"form-label",children:"Manifest URL"}),e.jsx("input",{value:u,onChange:s=>h(s.target.value),placeholder:"https://example.com/plugin.json",className:"form-control",required:!0})]}),e.jsxs(t.Footer,{children:[e.jsx(r,{type:"button",variant:"secondary",onClick:()=>{l(!1),i("")},children:"Cancel"}),e.jsx(r,{type:"submit",variant:"primary",disabled:x,children:x?"Adding…":"Add"})]})]})]}),e.jsx("div",{className:"row g-3",children:b.map(s=>e.jsx("div",{className:"col-md-6 col-xl-4",children:e.jsx("div",{className:"card h-100",children:e.jsxs("div",{className:"card-body d-flex gap-3",children:[e.jsx("i",{className:"ti ti-puzzle text-primary fs-2 flex-shrink-0","aria-hidden":!0}),e.jsxs("div",{className:"min-w-0 flex-grow-1",children:[e.jsxs("div",{className:"d-flex flex-wrap align-items-center gap-2 mb-1",children:[e.jsx("h3",{className:"h6 mb-0",children:s.name}),s.enabled?e.jsxs("span",{className:"badge bg-success-subtle text-success small",children:[e.jsx("i",{className:"ti ti-check me-1","aria-hidden":!0}),"Enabled"]}):null,s.builtin?null:e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-danger p-0 ms-auto",title:"Remove",onClick:()=>P(s.id),children:e.jsx("i",{className:"ti ti-trash","aria-hidden":!0})})]}),e.jsx("p",{className:"small text-secondary mb-1",children:s.desc}),e.jsxs("p",{className:"small text-muted mb-0",children:["v",s.version,s.builtin?" (built-in)":""]})]})]})})},s.id))})]})}export{F as PluginsPage};

View File

@@ -1 +1 @@
import{r,j as e,a as l}from"./index-cE9w-Kq7.js";import{A as S}from"./AdminAlert-yrdXFH0e.js";import{A as g}from"./AdminButton-ByutG8m-.js";import{A as y}from"./AdminTable-eCi7S__-.js";import{P as x}from"./PageHeader-HdM4gpcn.js";function u({show:d}){return d?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):null}function R(){const[d,j]=r.useState([]),[p,o]=r.useState(!0),[m,c]=r.useState(""),[n,s]=r.useState(null),i=()=>{o(!0),l("/service/list").then(t=>j(t.services||[])).catch(t=>c(t.message)).finally(()=>o(!1))};r.useEffect(()=>{i()},[]);const b=t=>{s(t),l(`/service/${t}/start`,{method:"POST"}).then(i).catch(a=>c(a.message)).finally(()=>s(null))},f=t=>{s(t),l(`/service/${t}/stop`,{method:"POST"}).then(i).catch(a=>c(a.message)).finally(()=>s(null))},v=t=>{s(t),l(`/service/${t}/restart`,{method:"POST"}).then(i).catch(a=>c(a.message)).finally(()=>s(null))},h=t=>t==="active"||t==="activating";return p?e.jsxs(e.Fragment,{children:[e.jsx(x,{title:"Services"}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(x,{title:"Services",actions:e.jsxs(g,{variant:"secondary",size:"sm",onClick:i,children:[e.jsx("i",{className:"ti ti-rotate-clockwise me-1","aria-hidden":!0}),"Refresh"]})}),m?e.jsx(S,{className:"mb-3",children:m}):null,e.jsx("div",{className:"alert alert-secondary small mb-3",children:"Control system services via systemctl. Requires panel to run with sufficient privileges."}),e.jsx("div",{className:"card",children:e.jsxs(y,{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Service"}),e.jsx("th",{children:"Unit"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"text-end",children:"Actions"})]})}),e.jsx("tbody",{children:d.map(t=>e.jsxs("tr",{children:[e.jsx("td",{children:t.name}),e.jsx("td",{className:"font-monospace small",children:t.unit}),e.jsx("td",{children:e.jsx("span",{className:h(t.status)?"text-success":"text-secondary",children:t.status})}),e.jsx("td",{className:"text-end",children:e.jsx("span",{className:"d-inline-flex gap-1 justify-content-end",children:h(t.status)?e.jsxs(e.Fragment,{children:[e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-warning p-1",title:"Restart",disabled:!!n,onClick:()=>v(t.id),children:n===t.id?e.jsx(u,{show:!0}):e.jsx("i",{className:"ti ti-rotate-clockwise","aria-hidden":!0})}),e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-danger p-1",title:"Stop",disabled:!!n,onClick:()=>f(t.id),children:e.jsx("i",{className:"ti ti-square","aria-hidden":!0})})]}):e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-success p-1",title:"Start",disabled:!!n,onClick:()=>b(t.id),children:n===t.id?e.jsx(u,{show:!0}):e.jsx("i",{className:"ti ti-player-play","aria-hidden":!0})})})})]},t.id))})]})})]})}export{R as ServicesPage}; import{r,j as e,a as l}from"./index-CRR9sQ49.js";import{A as S}from"./AdminAlert-DW1IRWce.js";import{A as g}from"./AdminButton-Bd2cLTu3.js";import{A as y}from"./AdminTable-BLiLxfnS.js";import{P as x}from"./PageHeader-BcjNf7GG.js";function u({show:d}){return d?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):null}function R(){const[d,j]=r.useState([]),[p,o]=r.useState(!0),[m,c]=r.useState(""),[n,s]=r.useState(null),i=()=>{o(!0),l("/service/list").then(t=>j(t.services||[])).catch(t=>c(t.message)).finally(()=>o(!1))};r.useEffect(()=>{i()},[]);const b=t=>{s(t),l(`/service/${t}/start`,{method:"POST"}).then(i).catch(a=>c(a.message)).finally(()=>s(null))},f=t=>{s(t),l(`/service/${t}/stop`,{method:"POST"}).then(i).catch(a=>c(a.message)).finally(()=>s(null))},v=t=>{s(t),l(`/service/${t}/restart`,{method:"POST"}).then(i).catch(a=>c(a.message)).finally(()=>s(null))},h=t=>t==="active"||t==="activating";return p?e.jsxs(e.Fragment,{children:[e.jsx(x,{title:"Services"}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(x,{title:"Services",actions:e.jsxs(g,{variant:"secondary",size:"sm",onClick:i,children:[e.jsx("i",{className:"ti ti-rotate-clockwise me-1","aria-hidden":!0}),"Refresh"]})}),m?e.jsx(S,{className:"mb-3",children:m}):null,e.jsx("div",{className:"alert alert-secondary small mb-3",children:"Control system services via systemctl. Requires panel to run with sufficient privileges."}),e.jsx("div",{className:"card",children:e.jsxs(y,{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Service"}),e.jsx("th",{children:"Unit"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"text-end",children:"Actions"})]})}),e.jsx("tbody",{children:d.map(t=>e.jsxs("tr",{children:[e.jsx("td",{children:t.name}),e.jsx("td",{className:"font-monospace small",children:t.unit}),e.jsx("td",{children:e.jsx("span",{className:h(t.status)?"text-success":"text-secondary",children:t.status})}),e.jsx("td",{className:"text-end",children:e.jsx("span",{className:"d-inline-flex gap-1 justify-content-end",children:h(t.status)?e.jsxs(e.Fragment,{children:[e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-warning p-1",title:"Restart",disabled:!!n,onClick:()=>v(t.id),children:n===t.id?e.jsx(u,{show:!0}):e.jsx("i",{className:"ti ti-rotate-clockwise","aria-hidden":!0})}),e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-danger p-1",title:"Stop",disabled:!!n,onClick:()=>f(t.id),children:e.jsx("i",{className:"ti ti-square","aria-hidden":!0})})]}):e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-success p-1",title:"Start",disabled:!!n,onClick:()=>b(t.id),children:n===t.id?e.jsx(u,{show:!0}):e.jsx("i",{className:"ti ti-player-play","aria-hidden":!0})})})})]},t.id))})]})})]})}export{R as ServicesPage};

View File

@@ -1 +1 @@
import{r as t,j as e,a as c}from"./index-cE9w-Kq7.js";import{A as b}from"./AdminAlert-yrdXFH0e.js";import{A as v}from"./AdminButton-ByutG8m-.js";import{P as m}from"./PageHeader-HdM4gpcn.js";function A(){const[x,u]=t.useState([]),[p,d]=t.useState(!0),[o,a]=t.useState(""),[n,l]=t.useState(null),[f,h]=t.useState(""),r=()=>{d(!0),c("/soft/list").then(s=>{u(s.software||[]),h(s.package_manager||"")}).catch(s=>a(s.message)).finally(()=>d(!1))};t.useEffect(()=>{r()},[]);const j=s=>{l(s),a(""),c(`/soft/install/${s}`,{method:"POST"}).then(()=>r()).catch(i=>a(i.message)).finally(()=>l(null))},g=(s,i)=>{confirm(`Uninstall ${i}?`)&&(l(s),a(""),c(`/soft/uninstall/${s}`,{method:"POST"}).then(()=>r()).catch(N=>a(N.message)).finally(()=>l(null)))};return p?e.jsxs(e.Fragment,{children:[e.jsx(m,{title:"App Store"}),e.jsx("div",{className:"d-flex justify-content-center py-5",children:e.jsx("div",{className:"spinner-border text-primary",role:"status",children:e.jsx("span",{className:"visually-hidden",children:"Loading…"})})})]}):e.jsxs(e.Fragment,{children:[e.jsx(m,{title:"App Store",actions:e.jsxs(v,{variant:"secondary",onClick:r,children:[e.jsx("i",{className:"ti ti-refresh me-1"}),"Refresh"]})}),o?e.jsx(b,{variant:"danger",children:o}):null,e.jsxs("div",{className:"alert alert-warning",role:"note",children:["Installs use your server package manager (",f||"unknown","). Panel must run as root (or equivalent). Supported: apt, dnf/yum/microdnf, apk."]}),e.jsx("div",{className:"row g-3",children:x.map(s=>e.jsx("div",{className:"col-md-6 col-xl-4 d-flex",children:e.jsx("div",{className:"card flex-fill shadow-sm",children:e.jsxs("div",{className:"card-body d-flex flex-column",children:[e.jsxs("div",{className:"d-flex align-items-start justify-content-between gap-2 mb-2",children:[e.jsxs("div",{className:"d-flex align-items-start gap-2",children:[e.jsx("span",{className:"avatar avatar-md bg-primary-transparent text-primary rounded flex-shrink-0",children:e.jsx("i",{className:"ti ti-package fs-5","aria-hidden":!0})}),e.jsxs("div",{children:[e.jsx("h5",{className:"card-title mb-1",children:s.name}),e.jsx("p",{className:"text-muted small mb-0",children:s.desc})]})]}),s.installed?e.jsxs("span",{className:"text-success small text-nowrap",children:[e.jsx("i",{className:"ti ti-circle-check me-1"}),s.version||"Installed"]}):e.jsxs("span",{className:"text-muted small text-nowrap",children:[e.jsx("i",{className:"ti ti-x me-1"}),"Not installed"]})]}),e.jsx("div",{className:"mt-auto pt-3",children:s.installed?e.jsxs("button",{type:"button",onClick:()=>g(s.id,s.name),disabled:n===s.id,className:"btn btn-outline-danger btn-sm w-100 d-inline-flex align-items-center justify-content-center gap-2",children:[n===s.id?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):null,"Uninstall"]}):e.jsxs("button",{type:"button",onClick:()=>j(s.id),disabled:n===s.id,className:"btn btn-primary btn-sm w-100 d-inline-flex align-items-center justify-content-center gap-2",children:[n===s.id?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):null,"Install"]})})]})})},s.id))})]})}export{A as SoftPage}; import{r as t,j as e,a as c}from"./index-CRR9sQ49.js";import{A as b}from"./AdminAlert-DW1IRWce.js";import{A as v}from"./AdminButton-Bd2cLTu3.js";import{P as m}from"./PageHeader-BcjNf7GG.js";function A(){const[x,u]=t.useState([]),[p,d]=t.useState(!0),[o,a]=t.useState(""),[n,l]=t.useState(null),[f,h]=t.useState(""),r=()=>{d(!0),c("/soft/list").then(s=>{u(s.software||[]),h(s.package_manager||"")}).catch(s=>a(s.message)).finally(()=>d(!1))};t.useEffect(()=>{r()},[]);const j=s=>{l(s),a(""),c(`/soft/install/${s}`,{method:"POST"}).then(()=>r()).catch(i=>a(i.message)).finally(()=>l(null))},g=(s,i)=>{confirm(`Uninstall ${i}?`)&&(l(s),a(""),c(`/soft/uninstall/${s}`,{method:"POST"}).then(()=>r()).catch(N=>a(N.message)).finally(()=>l(null)))};return p?e.jsxs(e.Fragment,{children:[e.jsx(m,{title:"App Store"}),e.jsx("div",{className:"d-flex justify-content-center py-5",children:e.jsx("div",{className:"spinner-border text-primary",role:"status",children:e.jsx("span",{className:"visually-hidden",children:"Loading…"})})})]}):e.jsxs(e.Fragment,{children:[e.jsx(m,{title:"App Store",actions:e.jsxs(v,{variant:"secondary",onClick:r,children:[e.jsx("i",{className:"ti ti-refresh me-1"}),"Refresh"]})}),o?e.jsx(b,{variant:"danger",children:o}):null,e.jsxs("div",{className:"alert alert-warning",role:"note",children:["Installs use your server package manager (",f||"unknown","). Panel must run as root (or equivalent). Supported: apt, dnf/yum/microdnf, apk."]}),e.jsx("div",{className:"row g-3",children:x.map(s=>e.jsx("div",{className:"col-md-6 col-xl-4 d-flex",children:e.jsx("div",{className:"card flex-fill shadow-sm",children:e.jsxs("div",{className:"card-body d-flex flex-column",children:[e.jsxs("div",{className:"d-flex align-items-start justify-content-between gap-2 mb-2",children:[e.jsxs("div",{className:"d-flex align-items-start gap-2",children:[e.jsx("span",{className:"avatar avatar-md bg-primary-transparent text-primary rounded flex-shrink-0",children:e.jsx("i",{className:"ti ti-package fs-5","aria-hidden":!0})}),e.jsxs("div",{children:[e.jsx("h5",{className:"card-title mb-1",children:s.name}),e.jsx("p",{className:"text-muted small mb-0",children:s.desc})]})]}),s.installed?e.jsxs("span",{className:"text-success small text-nowrap",children:[e.jsx("i",{className:"ti ti-circle-check me-1"}),s.version||"Installed"]}):e.jsxs("span",{className:"text-muted small text-nowrap",children:[e.jsx("i",{className:"ti ti-x me-1"}),"Not installed"]})]}),e.jsx("div",{className:"mt-auto pt-3",children:s.installed?e.jsxs("button",{type:"button",onClick:()=>g(s.id,s.name),disabled:n===s.id,className:"btn btn-outline-danger btn-sm w-100 d-inline-flex align-items-center justify-content-center gap-2",children:[n===s.id?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):null,"Uninstall"]}):e.jsxs("button",{type:"button",onClick:()=>j(s.id),disabled:n===s.id,className:"btn btn-primary btn-sm w-100 d-inline-flex align-items-center justify-content-center gap-2",children:[n===s.id?e.jsx("span",{className:"spinner-border spinner-border-sm",role:"status"}):null,"Install"]})})]})})},s.id))})]})}export{A as SoftPage};

View File

@@ -1 +0,0 @@
import{r as a,j as e,_,a as I,$ as D,a0 as P,a1 as L}from"./index-cE9w-Kq7.js";import{M as n}from"./Modal-CL3xZqxR.js";import{A as b}from"./AdminAlert-yrdXFH0e.js";import{A as o}from"./AdminButton-ByutG8m-.js";import{A as T}from"./AdminTable-eCi7S__-.js";import{P as g}from"./PageHeader-HdM4gpcn.js";function M(){const[h,v]=a.useState([]),[N,x]=a.useState(!0),[u,d]=a.useState(""),[y,l]=a.useState(!1),[j,p]=a.useState(!1),[f,i]=a.useState(""),[U,A]=a.useState(null),c=()=>{x(!0),_().then(s=>{v(s),I("/auth/me").then(t=>A(t.id))}).catch(s=>d(s.message)).finally(()=>x(!1))};a.useEffect(()=>{c()},[]);const C=s=>{s.preventDefault();const t=s.currentTarget,r=t.elements.namedItem("username").value.trim(),m=t.elements.namedItem("password").value,E=t.elements.namedItem("email").value.trim();if(!r||r.length<2){i("Username must be at least 2 characters");return}if(!m||m.length<6){i("Password must be at least 6 characters");return}p(!0),i(""),L({username:r,password:m,email:E}).then(()=>{l(!1),t.reset(),c()}).catch(k=>i(k.message)).finally(()=>p(!1))},w=(s,t)=>{confirm(`Delete user "${t}"?`)&&P(s).then(c).catch(r=>d(r.message))},S=s=>{D(s).then(c).catch(t=>d(t.message))};return N&&h.length===0?e.jsxs(e.Fragment,{children:[e.jsx(g,{title:"Users"}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(g,{title:"Users",actions:e.jsxs(o,{variant:"primary",onClick:()=>l(!0),children:[e.jsx("i",{className:"ti ti-plus me-1","aria-hidden":!0}),"Add User"]})}),u?e.jsx(b,{className:"mb-3",children:u}):null,e.jsx("div",{className:"card",children:e.jsxs(T,{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Email"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Role"}),e.jsx("th",{className:"text-end",children:"Actions"})]})}),e.jsx("tbody",{children:h.map(s=>e.jsxs("tr",{children:[e.jsx("td",{children:s.username}),e.jsx("td",{children:s.email||"—"}),e.jsx("td",{children:e.jsx("span",{className:s.is_active?"text-success":"text-secondary",children:s.is_active?"Active":"Inactive"})}),e.jsx("td",{children:s.is_superuser?"Admin":"User"}),e.jsx("td",{className:"text-end",children:s.id!==U?e.jsxs("span",{className:"d-inline-flex gap-1 justify-content-end",children:[e.jsx("button",{type:"button",className:`btn btn-link btn-sm p-1 ${s.is_active?"text-warning":"text-success"}`,title:s.is_active?"Deactivate":"Activate",onClick:()=>S(s.id),children:e.jsx("i",{className:s.is_active?"ti ti-user-x":"ti ti-user-check","aria-hidden":!0})}),e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-danger p-1",title:"Delete",onClick:()=>w(s.id,s.username),children:e.jsx("i",{className:"ti ti-trash","aria-hidden":!0})})]}):null})]},s.id))})]})}),e.jsxs(n,{show:y,onHide:()=>l(!1),centered:!0,children:[e.jsx(n.Header,{closeButton:!0,children:e.jsx(n.Title,{children:"Add User"})}),e.jsxs("form",{onSubmit:C,children:[e.jsxs(n.Body,{children:[f?e.jsx(b,{className:"mb-3",children:f}):null,e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Username"}),e.jsx("input",{name:"username",type:"text",placeholder:"newuser",className:"form-control",required:!0,minLength:2})]}),e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Password"}),e.jsx("input",{name:"password",type:"password",placeholder:"••••••••",className:"form-control",required:!0,minLength:6})]}),e.jsxs("div",{className:"mb-0",children:[e.jsx("label",{className:"form-label",children:"Email (optional)"}),e.jsx("input",{name:"email",type:"email",placeholder:"user@example.com",className:"form-control"})]})]}),e.jsxs(n.Footer,{children:[e.jsx(o,{type:"button",variant:"secondary",onClick:()=>l(!1),children:"Cancel"}),e.jsx(o,{type:"submit",variant:"primary",disabled:j,children:j?"Creating…":"Create"})]})]})]})]})}export{M as UsersPage};

View File

@@ -0,0 +1 @@
import{r as a,j as e,a6 as I,a as _,a7 as D,a8 as P,a9 as L}from"./index-CRR9sQ49.js";import{M as n}from"./Modal-B7V4w_St.js";import{A as b}from"./AdminAlert-DW1IRWce.js";import{A as o}from"./AdminButton-Bd2cLTu3.js";import{A as T}from"./AdminTable-BLiLxfnS.js";import{P as g}from"./PageHeader-BcjNf7GG.js";function $(){const[h,v]=a.useState([]),[N,x]=a.useState(!0),[u,d]=a.useState(""),[y,l]=a.useState(!1),[j,p]=a.useState(!1),[f,i]=a.useState(""),[U,A]=a.useState(null),c=()=>{x(!0),I().then(s=>{v(s),_("/auth/me").then(t=>A(t.id))}).catch(s=>d(s.message)).finally(()=>x(!1))};a.useEffect(()=>{c()},[]);const C=s=>{s.preventDefault();const t=s.currentTarget,r=t.elements.namedItem("username").value.trim(),m=t.elements.namedItem("password").value,E=t.elements.namedItem("email").value.trim();if(!r||r.length<2){i("Username must be at least 2 characters");return}if(!m||m.length<6){i("Password must be at least 6 characters");return}p(!0),i(""),L({username:r,password:m,email:E}).then(()=>{l(!1),t.reset(),c()}).catch(k=>i(k.message)).finally(()=>p(!1))},w=(s,t)=>{confirm(`Delete user "${t}"?`)&&P(s).then(c).catch(r=>d(r.message))},S=s=>{D(s).then(c).catch(t=>d(t.message))};return N&&h.length===0?e.jsxs(e.Fragment,{children:[e.jsx(g,{title:"Users"}),e.jsx("p",{className:"text-secondary",children:"Loading…"})]}):e.jsxs(e.Fragment,{children:[e.jsx(g,{title:"Users",actions:e.jsxs(o,{variant:"primary",onClick:()=>l(!0),children:[e.jsx("i",{className:"ti ti-plus me-1","aria-hidden":!0}),"Add User"]})}),u?e.jsx(b,{className:"mb-3",children:u}):null,e.jsx("div",{className:"card",children:e.jsxs(T,{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Email"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Role"}),e.jsx("th",{className:"text-end",children:"Actions"})]})}),e.jsx("tbody",{children:h.map(s=>e.jsxs("tr",{children:[e.jsx("td",{children:s.username}),e.jsx("td",{children:s.email||"—"}),e.jsx("td",{children:e.jsx("span",{className:s.is_active?"text-success":"text-secondary",children:s.is_active?"Active":"Inactive"})}),e.jsx("td",{children:s.is_superuser?"Admin":"User"}),e.jsx("td",{className:"text-end",children:s.id!==U?e.jsxs("span",{className:"d-inline-flex gap-1 justify-content-end",children:[e.jsx("button",{type:"button",className:`btn btn-link btn-sm p-1 ${s.is_active?"text-warning":"text-success"}`,title:s.is_active?"Deactivate":"Activate",onClick:()=>S(s.id),children:e.jsx("i",{className:s.is_active?"ti ti-user-x":"ti ti-user-check","aria-hidden":!0})}),e.jsx("button",{type:"button",className:"btn btn-link btn-sm text-danger p-1",title:"Delete",onClick:()=>w(s.id,s.username),children:e.jsx("i",{className:"ti ti-trash","aria-hidden":!0})})]}):null})]},s.id))})]})}),e.jsxs(n,{show:y,onHide:()=>l(!1),centered:!0,children:[e.jsx(n.Header,{closeButton:!0,children:e.jsx(n.Title,{children:"Add User"})}),e.jsxs("form",{onSubmit:C,children:[e.jsxs(n.Body,{children:[f?e.jsx(b,{className:"mb-3",children:f}):null,e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Username"}),e.jsx("input",{name:"username",type:"text",placeholder:"newuser",className:"form-control",required:!0,minLength:2})]}),e.jsxs("div",{className:"mb-3",children:[e.jsx("label",{className:"form-label",children:"Password"}),e.jsx("input",{name:"password",type:"password",placeholder:"••••••••",className:"form-control",required:!0,minLength:6})]}),e.jsxs("div",{className:"mb-0",children:[e.jsx("label",{className:"form-label",children:"Email (optional)"}),e.jsx("input",{name:"email",type:"email",placeholder:"user@example.com",className:"form-control"})]})]}),e.jsxs(n.Footer,{children:[e.jsx(o,{type:"button",variant:"secondary",onClick:()=>l(!1),children:"Cancel"}),e.jsx(o,{type:"submit",variant:"primary",disabled:j,children:j?"Creating…":"Create"})]})]})]})]})}export{$ as UsersPage};

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YakPanel</title> <title>YakPanel</title>
<script type="module" crossorigin src="/assets/index-cE9w-Kq7.js"></script> <script type="module" crossorigin src="/assets/index-CRR9sQ49.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BHS5Y1YN.css"> <link rel="stylesheet" crossorigin href="/assets/index-BHS5Y1YN.css">
</head> </head>
<body> <body>

View File

@@ -163,12 +163,67 @@ export async function downloadSiteBackup(siteId: number, filename: string): Prom
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
export interface FileListItem {
name: string
is_dir: boolean
size: number
mtime?: string
mtime_ts?: number
mode?: string
mode_symbolic?: string
owner?: string
group?: string
}
export async function listFiles(path: string) { export async function listFiles(path: string) {
return apiRequest<{ path: string; items: { name: string; is_dir: boolean; size: number }[] }>( return apiRequest<{ path: string; items: FileListItem[] }>(`/files/list?path=${encodeURIComponent(path)}`)
`/files/list?path=${encodeURIComponent(path)}` }
export async function fileDirSize(path: string) {
return apiRequest<{ size: number }>(`/files/dir-size?path=${encodeURIComponent(path)}`)
}
export async function fileSearch(q: string, path: string, maxResults = 200) {
return apiRequest<{ path: string; query: string; results: { path: string; name: string; is_dir: boolean }[] }>(
`/files/search?q=${encodeURIComponent(q)}&path=${encodeURIComponent(path)}&max_results=${maxResults}`
) )
} }
export async function fileChmod(filePath: string, mode: string, recursive = false) {
return apiRequest<{ status: boolean; msg: string }>('/files/chmod', {
method: 'POST',
body: JSON.stringify({ file_path: filePath, mode, recursive }),
})
}
export async function fileTouch(parentPath: string, name: string) {
return apiRequest<{ status: boolean; msg: string }>('/files/touch', {
method: 'POST',
body: JSON.stringify({ path: parentPath, name }),
})
}
export async function fileCopy(sourceParent: string, name: string, destParent: string, destName?: string) {
return apiRequest<{ status: boolean; msg: string }>('/files/copy', {
method: 'POST',
body: JSON.stringify({ path: sourceParent, name, dest_path: destParent, dest_name: destName ?? null }),
})
}
export async function fileMove(sourceParent: string, name: string, destParent: string, destName?: string) {
return apiRequest<{ status: boolean; msg: string }>('/files/move', {
method: 'POST',
body: JSON.stringify({ path: sourceParent, name, dest_path: destParent, dest_name: destName ?? null }),
})
}
export async function fileCompress(parentPath: string, names: string[], archiveName: string) {
return apiRequest<{ status: boolean; msg: string; archive?: string }>('/files/compress', {
method: 'POST',
body: JSON.stringify({ path: parentPath, names, archive_name: archiveName }),
})
}
export async function uploadFile(path: string, file: File) { export async function uploadFile(path: string, file: File) {
const form = new FormData() const form = new FormData()
form.append('path', path) form.append('path', path)

View File

@@ -1,27 +1,58 @@
import { useEffect, useState, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Modal from 'react-bootstrap/Modal' import Modal from 'react-bootstrap/Modal'
import { listFiles, downloadFile, uploadFile, readFile, writeFile, mkdirFile, renameFile, deleteFile } from '../api/client' import Dropdown from 'react-bootstrap/Dropdown'
import {
listFiles,
downloadFile,
uploadFile,
readFile,
writeFile,
mkdirFile,
renameFile,
deleteFile,
fileDirSize,
fileSearch,
fileChmod,
fileTouch,
fileCopy,
fileMove,
fileCompress,
type FileListItem,
} from '../api/client'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin' import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface FileItem { function joinPath(dir: string, name: string): string {
name: string if (dir === '/') return `/${name}`
is_dir: boolean return `${dir.replace(/\/$/, '')}/${name}`
size: number }
function parentPath(p: string): string {
const parts = p.replace(/\/$/, '').split('/').filter(Boolean)
parts.pop()
return parts.length === 0 ? '/' : `/${parts.join('/')}`
} }
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B' if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return (bytes / 1024 / 1024).toFixed(1) + ' MB' if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
} }
const TEXT_EXT = ['.txt', '.html', '.htm', '.css', '.js', '.json', '.xml', '.md', '.py', '.php', '.sh', '.conf', '.env'] const TEXT_EXT = ['.txt', '.html', '.htm', '.css', '.js', '.json', '.xml', '.md', '.py', '.php', '.sh', '.conf', '.env', '.ini', '.log', '.yml', '.yaml']
type Clip = { op: 'copy' | 'cut'; entries: { parent: string; name: string }[] }
export function FilesPage() { export function FilesPage() {
const [path, setPath] = useState('/') const [path, setPath] = useState('/')
const [items, setItems] = useState<FileItem[]>([]) const [pathInput, setPathInput] = useState('/')
const [items, setItems] = useState<FileListItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [filter, setFilter] = useState('')
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const [clipboard, setClipboard] = useState<Clip | null>(null)
const [dirSizes, setDirSizes] = useState<Record<string, number>>({})
const [downloading, setDownloading] = useState<string | null>(null) const [downloading, setDownloading] = useState<string | null>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [editingFile, setEditingFile] = useState<string | null>(null) const [editingFile, setEditingFile] = useState<string | null>(null)
@@ -29,44 +60,86 @@ export function FilesPage() {
const [savingEdit, setSavingEdit] = useState(false) const [savingEdit, setSavingEdit] = useState(false)
const [showMkdir, setShowMkdir] = useState(false) const [showMkdir, setShowMkdir] = useState(false)
const [mkdirName, setMkdirName] = useState('') const [mkdirName, setMkdirName] = useState('')
const [renaming, setRenaming] = useState<FileItem | null>(null) const [showNewFile, setShowNewFile] = useState(false)
const [newFileName, setNewFileName] = useState('')
const [renaming, setRenaming] = useState<FileListItem | null>(null)
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const [showChmod, setShowChmod] = useState(false)
const [chmodItem, setChmodItem] = useState<FileListItem | null>(null)
const [chmodMode, setChmodMode] = useState('0644')
const [chmodRecursive, setChmodRecursive] = useState(false)
const [showCompress, setShowCompress] = useState(false)
const [compressName, setCompressName] = useState('archive.zip')
const [showSearchModal, setShowSearchModal] = useState(false)
const [searchQ, setSearchQ] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchResults, setSearchResults] = useState<{ path: string; name: string; is_dir: boolean }[]>([])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const loadDir = (p: string) => { const loadDir = useCallback((p: string) => {
setLoading(true) setLoading(true)
setError('') setError('')
setSelected(new Set())
listFiles(p) listFiles(p)
.then((data) => { .then((data) => {
setPath(data.path) setPath(data.path)
setPathInput(data.path)
setItems(data.items.sort((a, b) => (a.is_dir === b.is_dir ? 0 : a.is_dir ? -1 : 1))) setItems(data.items.sort((a, b) => (a.is_dir === b.is_dir ? 0 : a.is_dir ? -1 : 1)))
setDirSizes({})
}) })
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }, [])
useEffect(() => { useEffect(() => {
loadDir(path) loadDir(path)
}, []) }, [])
const handleNavigate = (item: FileItem) => { const filteredItems = useMemo(() => {
if (item.is_dir) { const q = filter.trim().toLowerCase()
const newPath = path.endsWith('/') ? path + item.name : path + '/' + item.name if (!q) return items
loadDir(newPath) return items.filter((i) => i.name.toLowerCase().includes(q))
}, [items, filter])
const pathSegments = path.replace(/\/$/, '').split('/').filter(Boolean)
const canGoBack = pathSegments.length > 0
const toggleSelect = (name: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
} }
const selectAll = () => {
if (selected.size === filteredItems.length) setSelected(new Set())
else setSelected(new Set(filteredItems.map((i) => i.name)))
}
const selectedList = useMemo(
() => filteredItems.filter((i) => selected.has(i.name)),
[filteredItems, selected]
)
const handleNavigate = (item: FileListItem) => {
if (item.is_dir) loadDir(joinPath(path, item.name))
} }
const handleBack = () => { const handleBack = () => {
const parts = path.replace(/\/$/, '').split('/').filter(Boolean) if (!canGoBack) return
if (parts.length === 0) return loadDir(parentPath(path))
parts.pop()
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/')
loadDir(newPath)
} }
const handleDownload = (item: FileItem) => { const goPathInput = () => {
const p = pathInput.trim() || '/'
loadDir(p.startsWith('/') ? p : `/${p}`)
}
const handleDownload = (item: FileListItem) => {
if (item.is_dir) return if (item.is_dir) return
const fullPath = path.endsWith('/') ? path + item.name : path + '/' + item.name const fullPath = joinPath(path, item.name)
setDownloading(item.name) setDownloading(item.name)
downloadFile(fullPath) downloadFile(fullPath)
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
@@ -87,8 +160,8 @@ export function FilesPage() {
}) })
} }
const handleEdit = (item: FileItem) => { const handleEdit = (item: FileListItem) => {
const fullPath = path.endsWith('/') ? path + item.name : path + '/' + item.name const fullPath = joinPath(path, item.name)
readFile(fullPath) readFile(fullPath)
.then((data) => { .then((data) => {
setEditingFile(fullPath) setEditingFile(fullPath)
@@ -124,6 +197,19 @@ export function FilesPage() {
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
} }
const handleTouch = (e: React.FormEvent) => {
e.preventDefault()
const name = newFileName.trim()
if (!name) return
fileTouch(path, name)
.then(() => {
setShowNewFile(false)
setNewFileName('')
loadDir(path)
})
.catch((err) => setError(err.message))
}
const handleRename = () => { const handleRename = () => {
if (!renaming || !renameValue.trim()) return if (!renaming || !renameValue.trim()) return
const newName = renameValue.trim() const newName = renameValue.trim()
@@ -140,44 +226,253 @@ export function FilesPage() {
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
} }
const handleDelete = (item: FileItem) => { const handleDelete = (item: FileListItem) => {
if (!confirm(`Delete ${item.is_dir ? 'folder' : 'file'} "${item.name}"?`)) return if (!confirm(`Delete ${item.is_dir ? 'folder' : 'file'} "${item.name}"?`)) return
deleteFile(path, item.name, item.is_dir) deleteFile(path, item.name, item.is_dir)
.then(() => loadDir(path)) .then(() => loadDir(path))
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
} }
const pathSegments = path.replace(/\/$/, '').split('/').filter(Boolean) const batchDelete = () => {
const canGoBack = pathSegments.length > 0 if (selectedList.length === 0) return
if (!confirm(`Delete ${selectedList.length} item(s)?`)) return
Promise.all(
selectedList.map((i) => deleteFile(path, i.name, i.is_dir))
)
.then(() => loadDir(path))
.catch((err) => setError(err.message))
}
const copySelection = () => {
if (selectedList.length === 0) return
setClipboard({
op: 'copy',
entries: selectedList.map((i) => ({ parent: path, name: i.name })),
})
}
const cutSelection = () => {
if (selectedList.length === 0) return
setClipboard({
op: 'cut',
entries: selectedList.map((i) => ({ parent: path, name: i.name })),
})
}
const pasteHere = () => {
if (!clipboard || clipboard.entries.length === 0) return
const tasks = clipboard.entries.map((e) => {
if (clipboard.op === 'copy') return fileCopy(e.parent, e.name, path)
return fileMove(e.parent, e.name, path)
})
Promise.all(tasks)
.then(() => {
if (clipboard?.op === 'cut') setClipboard(null)
loadDir(path)
})
.catch((err) => setError(err.message))
}
const openChmod = (item: FileListItem) => {
setChmodItem(item)
setChmodMode(item.mode ? item.mode.padStart(3, '0') : '0644')
setChmodRecursive(item.is_dir)
setShowChmod(true)
}
const submitChmod = () => {
if (!chmodItem) return
const fp = joinPath(path, chmodItem.name)
fileChmod(fp, chmodMode, chmodRecursive && chmodItem.is_dir)
.then(() => {
setShowChmod(false)
setChmodItem(null)
loadDir(path)
})
.catch((err) => setError(err.message))
}
const openCompress = () => {
const names = selectedList.length > 0 ? selectedList.map((i) => i.name) : []
if (names.length === 0) return
setCompressName(`archive-${Date.now()}.zip`)
setShowCompress(true)
}
const submitCompress = () => {
const names = selectedList.length > 0 ? selectedList.map((i) => i.name) : []
if (!compressName.trim() || names.length === 0) return
fileCompress(path, names, compressName.trim())
.then(() => {
setShowCompress(false)
loadDir(path)
})
.catch((err) => setError(err.message))
}
const calcDirSize = (item: FileListItem) => {
if (!item.is_dir) return
const fp = joinPath(path, item.name)
fileDirSize(fp)
.then((r) => setDirSizes((d) => ({ ...d, [item.name]: r.size })))
.catch((err) => setError(err.message))
}
const runDeepSearch = () => {
const q = searchQ.trim()
if (!q) return
setSearchLoading(true)
setSearchResults([])
fileSearch(q, path, 300)
.then((r) => {
setSearchResults(r.results)
setShowSearchModal(true)
})
.catch((err) => setError(err.message))
.finally(() => setSearchLoading(false))
}
const navigateToSearchHit = (hit: { path: string; is_dir: boolean }) => {
setShowSearchModal(false)
if (hit.is_dir) loadDir(hit.path)
else loadDir(parentPath(hit.path))
}
const breadcrumbNavigate = (idx: number) => {
if (idx < 0) {
loadDir('/')
return
}
const segs = pathSegments.slice(0, idx + 1)
loadDir(`/${segs.join('/')}`)
}
return ( return (
<> <>
<PageHeader title="Files" /> <PageHeader title="Files" />
<div className="d-flex flex-wrap align-items-center gap-2 mb-3"> <div className="card mb-3">
<div className="card-body py-2">
<div className="d-flex flex-wrap align-items-center gap-2 mb-2">
<AdminButton variant="secondary" size="sm" onClick={handleBack} disabled={!canGoBack}> <AdminButton variant="secondary" size="sm" onClick={handleBack} disabled={!canGoBack}>
<i className="ti ti-arrow-left me-1" aria-hidden /> <i className="ti ti-arrow-left me-1" aria-hidden />
Back Back
</AdminButton> </AdminButton>
<input ref={fileInputRef} type="file" className="d-none" onChange={handleUpload} /> <AdminButton variant="outline-secondary" size="sm" onClick={() => loadDir(path)} disabled={loading}>
<AdminButton variant="success" size="sm" onClick={() => setShowMkdir(true)}> <i className="ti ti-refresh me-1" aria-hidden />
<i className="ti ti-folder-plus me-1" aria-hidden /> Refresh
New Folder
</AdminButton> </AdminButton>
<Dropdown as="span">
<Dropdown.Toggle variant="success" size="sm" id="files-new-dropdown">
<i className="ti ti-plus me-1" aria-hidden />
New
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item onClick={() => setShowMkdir(true)}>
<i className="ti ti-folder-plus me-2" />
Folder
</Dropdown.Item>
<Dropdown.Item onClick={() => setShowNewFile(true)}>
<i className="ti ti-file-plus me-2" />
File
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<input ref={fileInputRef} type="file" className="d-none" onChange={handleUpload} />
<AdminButton variant="primary" size="sm" onClick={() => fileInputRef.current?.click()} disabled={uploading}> <AdminButton variant="primary" size="sm" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? ( {uploading ? <span className="spinner-border spinner-border-sm me-1" role="status" /> : <i className="ti ti-upload me-1" aria-hidden />}
<span className="spinner-border spinner-border-sm me-1" role="status" />
) : (
<i className="ti ti-upload me-1" aria-hidden />
)}
Upload Upload
</AdminButton> </AdminButton>
<code className="small bg-body-secondary px-2 py-1 rounded ms-auto text-break">Path: {path || '/'}</code> {selectedList.length === 1 && !selectedList[0].is_dir ? (
<AdminButton variant="outline-primary" size="sm" onClick={() => handleDownload(selectedList[0])}>
<i className="ti ti-download me-1" aria-hidden />
Download
</AdminButton>
) : null}
<AdminButton variant="outline-secondary" size="sm" onClick={copySelection} disabled={selectedList.length === 0}>
<i className="ti ti-copy me-1" aria-hidden />
Copy
</AdminButton>
<AdminButton variant="outline-secondary" size="sm" onClick={cutSelection} disabled={selectedList.length === 0}>
<i className="ti ti-cut me-1" aria-hidden />
Cut
</AdminButton>
<AdminButton variant="outline-primary" size="sm" onClick={pasteHere} disabled={!clipboard || clipboard.entries.length === 0}>
<i className="ti ti-clipboard me-1" aria-hidden />
Paste
</AdminButton>
<AdminButton variant="warning" size="sm" onClick={openCompress} disabled={selectedList.length === 0}>
<i className="ti ti-file-zip me-1" aria-hidden />
Compress
</AdminButton>
<AdminButton variant="outline-danger" size="sm" onClick={batchDelete} disabled={selectedList.length === 0}>
<i className="ti ti-trash me-1" aria-hidden />
Delete
</AdminButton>
</div>
<div className="d-flex flex-wrap align-items-center gap-2">
<div className="input-group input-group-sm" style={{ minWidth: 240, maxWidth: 480 }}>
<span className="input-group-text">Path</span>
<input
className="form-control font-monospace small"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && goPathInput()}
/>
<AdminButton variant="primary" size="sm" className="rounded-0 rounded-end" type="button" onClick={goPathInput}>
Go
</AdminButton>
</div>
<div className="input-group input-group-sm flex-grow-1" style={{ minWidth: 200 }}>
<span className="input-group-text">
<i className="ti ti-search" aria-hidden />
</span>
<input
className="form-control"
placeholder="Filter current folder…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className="input-group input-group-sm" style={{ minWidth: 200 }}>
<input
className="form-control"
placeholder="Search subfolders…"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && runDeepSearch()}
/>
<AdminButton variant="outline-secondary" size="sm" type="button" onClick={runDeepSearch} disabled={searchLoading}>
{searchLoading ? <span className="spinner-border spinner-border-sm" role="status" /> : 'Search'}
</AdminButton>
</div>
</div>
<nav aria-label="breadcrumb" className="mt-2 mb-0">
<ol className="breadcrumb mb-0 small py-1">
<li className="breadcrumb-item">
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => loadDir('/')}>
/
</button>
</li>
{pathSegments.map((seg, i) => (
<li key={`${seg}-${i}`} className={`breadcrumb-item${i === pathSegments.length - 1 ? ' active' : ''}`}>
{i === pathSegments.length - 1 ? (
seg
) : (
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => breadcrumbNavigate(i)}>
{seg}
</button>
)}
</li>
))}
</ol>
</nav>
</div>
</div> </div>
<Modal show={showMkdir} onHide={() => { setShowMkdir(false); setMkdirName('') }} centered> <Modal show={showMkdir} onHide={() => { setShowMkdir(false); setMkdirName('') }} centered>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title>New Folder</Modal.Title> <Modal.Title>New folder</Modal.Title>
</Modal.Header> </Modal.Header>
<form onSubmit={handleMkdir}> <form onSubmit={handleMkdir}>
<Modal.Body> <Modal.Body>
@@ -200,6 +495,109 @@ export function FilesPage() {
</form> </form>
</Modal> </Modal>
<Modal show={showNewFile} onHide={() => { setShowNewFile(false); setNewFileName('') }} centered>
<Modal.Header closeButton>
<Modal.Title>New file</Modal.Title>
</Modal.Header>
<form onSubmit={handleTouch}>
<Modal.Body>
<input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="filename.txt"
className="form-control"
autoFocus
/>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => { setShowNewFile(false); setNewFileName('') }}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary">
Create
</AdminButton>
</Modal.Footer>
</form>
</Modal>
<Modal show={showChmod} onHide={() => { setShowChmod(false); setChmodItem(null) }} centered>
<Modal.Header closeButton>
<Modal.Title>Permissions</Modal.Title>
</Modal.Header>
<Modal.Body>
{chmodItem ? (
<>
<p className="small font-monospace text-break mb-2">{joinPath(path, chmodItem.name)}</p>
<label className="form-label small">Mode (octal)</label>
<input className="form-control mb-2 font-monospace" value={chmodMode} onChange={(e) => setChmodMode(e.target.value)} placeholder="0644" />
{chmodItem.is_dir ? (
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="chmod-rec"
checked={chmodRecursive}
onChange={(e) => setChmodRecursive(e.target.checked)}
/>
<label className="form-check-label small" htmlFor="chmod-rec">
Recursive (chmod entire tree)
</label>
</div>
) : null}
</>
) : null}
</Modal.Body>
<Modal.Footer>
<AdminButton variant="secondary" onClick={() => { setShowChmod(false); setChmodItem(null) }}>
Cancel
</AdminButton>
<AdminButton variant="primary" onClick={submitChmod}>
Apply
</AdminButton>
</Modal.Footer>
</Modal>
<Modal show={showCompress} onHide={() => setShowCompress(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Compress to ZIP</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="small text-secondary mb-2">{selectedList.length} item(s) selected</p>
<label className="form-label small">Archive name</label>
<input className="form-control font-monospace" value={compressName} onChange={(e) => setCompressName(e.target.value)} />
</Modal.Body>
<Modal.Footer>
<AdminButton variant="secondary" onClick={() => setShowCompress(false)}>
Cancel
</AdminButton>
<AdminButton variant="primary" onClick={submitCompress}>
Create ZIP
</AdminButton>
</Modal.Footer>
</Modal>
<Modal show={showSearchModal} onHide={() => setShowSearchModal(false)} size="lg" scrollable>
<Modal.Header closeButton>
<Modal.Title>Search results{searchQ ? `: “${searchQ}` : ''}</Modal.Title>
</Modal.Header>
<Modal.Body>
{searchResults.length === 0 ? (
<p className="text-secondary small mb-0">No matches.</p>
) : (
<ul className="list-group list-group-flush">
{searchResults.map((r) => (
<li key={r.path} className="list-group-item d-flex justify-content-between align-items-center">
<span className="small font-monospace text-break me-2">{r.path}</span>
<AdminButton size="sm" variant="outline-primary" onClick={() => navigateToSearchHit(r)}>
Open
</AdminButton>
</li>
))}
</ul>
)}
</Modal.Body>
</Modal>
<Modal show={!!editingFile} onHide={() => setEditingFile(null)} fullscreen="lg-down" size="lg"> <Modal show={!!editingFile} onHide={() => setEditingFile(null)} fullscreen="lg-down" size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title className="text-break small font-monospace">{editingFile}</Modal.Title> <Modal.Title className="text-break small font-monospace">{editingFile}</Modal.Title>
@@ -231,35 +629,91 @@ export function FilesPage() {
<span className="spinner-border text-secondary" role="status" /> <span className="spinner-border text-secondary" role="status" />
</div> </div>
) : ( ) : (
<>
<AdminTable> <AdminTable>
<thead> <thead>
<tr> <tr>
<th style={{ width: 40 }}>
<input
type="checkbox"
className="form-check-input"
checked={filteredItems.length > 0 && selected.size === filteredItems.length}
onChange={selectAll}
aria-label="Select all"
/>
</th>
<th>Name</th> <th>Name</th>
<th>Size</th> <th>Size</th>
<th className="text-end">Actions</th> <th className="d-none d-lg-table-cell">Modified</th>
<th className="d-none d-md-table-cell">Permission</th>
<th className="d-none d-xl-table-cell">Owner</th>
<th className="text-end" style={{ minWidth: 120 }}>
Operation
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.length === 0 ? ( {filteredItems.length === 0 ? (
<tr> <tr>
<td colSpan={3} className="p-0"> <td colSpan={7} className="p-0">
<EmptyState title="Empty directory" description="Upload files or create a folder." /> <EmptyState title="Empty directory" description="Upload files, create a folder, or adjust the path filter." />
</td> </td>
</tr> </tr>
) : ( ) : (
items.map((item) => ( filteredItems.map((item) => (
<tr key={item.name}> <tr key={item.name}>
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
className="form-check-input"
checked={selected.has(item.name)}
onChange={() => toggleSelect(item.name)}
aria-label={`Select ${item.name}`}
/>
</td>
<td> <td>
{item.is_dir ? (
<button <button
type="button" type="button"
onClick={() => handleNavigate(item)} onClick={() => handleNavigate(item)}
className="btn btn-link text-start text-decoration-none p-0 d-inline-flex align-items-center gap-2" className="btn btn-link text-start text-decoration-none p-0 d-inline-flex align-items-center gap-2"
> >
<i className={`ti ${item.is_dir ? 'ti-folder text-warning' : 'ti-file text-secondary'}`} aria-hidden /> <i className="ti ti-folder text-warning" aria-hidden />
<span>{item.name}</span> <span>{item.name}</span>
</button> </button>
) : (
<span className="d-inline-flex align-items-center gap-2">
<i className="ti ti-file text-secondary" aria-hidden />
{item.name}
</span>
)}
</td>
<td className="text-secondary small">
{item.is_dir ? (
dirSizes[item.name] !== undefined ? (
formatSize(dirSizes[item.name])
) : (
<button type="button" className="btn btn-link btn-sm p-0" onClick={() => calcDirSize(item)}>
Calculate
</button>
)
) : (
formatSize(item.size)
)}
</td>
<td className="small text-secondary d-none d-lg-table-cell">{item.mtime ?? '—'}</td>
<td className="small font-monospace d-none d-md-table-cell">
{item.mode_symbolic ? (
<>
<span title={`${item.mode_symbolic} (${item.mode})`}>{item.mode_symbolic}</span>
</>
) : (
item.mode ?? '—'
)}
</td>
<td className="small d-none d-xl-table-cell">
{item.owner ? `${item.owner}:${item.group ?? ''}` : '—'}
</td> </td>
<td className="text-secondary">{item.is_dir ? '—' : formatSize(item.size)}</td>
<td className="text-end"> <td className="text-end">
{renaming?.name === item.name ? ( {renaming?.name === item.name ? (
<span className="d-inline-flex gap-1 align-items-center flex-wrap justify-content-end"> <span className="d-inline-flex gap-1 align-items-center flex-wrap justify-content-end">
@@ -268,7 +722,7 @@ export function FilesPage() {
onChange={(e) => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename()} onKeyDown={(e) => e.key === 'Enter' && handleRename()}
className="form-control form-control-sm" className="form-control form-control-sm"
style={{ width: '8rem' }} style={{ width: '7rem' }}
autoFocus autoFocus
/> />
<button type="button" className="btn btn-link btn-sm text-success p-1" title="Save" onClick={handleRename}> <button type="button" className="btn btn-link btn-sm text-success p-1" title="Save" onClick={handleRename}>
@@ -286,52 +740,69 @@ export function FilesPage() {
</button> </button>
</span> </span>
) : ( ) : (
<span className="d-inline-flex gap-1 justify-content-end"> <Dropdown align="end" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<button <Dropdown.Toggle variant="light" size="sm" className="py-0 border">
type="button" More
className="btn btn-link btn-sm text-secondary p-1" </Dropdown.Toggle>
title="Rename" <Dropdown.Menu>
{item.is_dir ? (
<Dropdown.Item onClick={() => handleNavigate(item)}>
<i className="ti ti-folder-open me-2" />
Open
</Dropdown.Item>
) : (
<>
<Dropdown.Item onClick={() => handleDownload(item)} disabled={downloading === item.name}>
<i className="ti ti-download me-2" />
Download
</Dropdown.Item>
{canEdit(item.name) ? (
<Dropdown.Item onClick={() => handleEdit(item)}>
<i className="ti ti-edit me-2" />
Edit
</Dropdown.Item>
) : null}
</>
)}
<Dropdown.Divider />
<Dropdown.Item onClick={() => { setClipboard({ op: 'copy', entries: [{ parent: path, name: item.name }] }) }}>
<i className="ti ti-copy me-2" />
Copy
</Dropdown.Item>
<Dropdown.Item onClick={() => { setClipboard({ op: 'cut', entries: [{ parent: path, name: item.name }] }) }}>
<i className="ti ti-cut me-2" />
Cut
</Dropdown.Item>
<Dropdown.Item
onClick={() => { onClick={() => {
setRenaming(item) setRenaming(item)
setRenameValue(item.name) setRenameValue(item.name)
}} }}
> >
<i className="ti ti-pencil" aria-hidden /> <i className="ti ti-pencil me-2" />
</button> Rename
{!item.is_dir && canEdit(item.name) ? ( </Dropdown.Item>
<button <Dropdown.Item onClick={() => openChmod(item)}>
type="button" <i className="ti ti-lock me-2" />
className="btn btn-link btn-sm text-warning p-1" Permission
title="Edit" </Dropdown.Item>
onClick={() => handleEdit(item)} <Dropdown.Item
onClick={() => {
setSelected(new Set([item.name]))
setCompressName(`${item.name.replace(/\.[^/.]+$/, '') || 'archive'}.zip`)
setShowCompress(true)
}}
> >
<i className="ti ti-edit" aria-hidden /> <i className="ti ti-file-zip me-2" />
</button> Compress
) : null} </Dropdown.Item>
{!item.is_dir ? ( <Dropdown.Divider />
<button <Dropdown.Item className="text-danger" onClick={() => handleDelete(item)}>
type="button" <i className="ti ti-trash me-2" />
className="btn btn-link btn-sm text-primary p-1" Delete
title="Download" </Dropdown.Item>
disabled={downloading === item.name} </Dropdown.Menu>
onClick={() => handleDownload(item)} </Dropdown>
>
{downloading === item.name ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : (
<i className="ti ti-download" aria-hidden />
)}
</button>
) : null}
<button
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Delete"
onClick={() => handleDelete(item)}
>
<i className="ti ti-trash" aria-hidden />
</button>
</span>
)} )}
</td> </td>
</tr> </tr>
@@ -339,8 +810,31 @@ export function FilesPage() {
)} )}
</tbody> </tbody>
</AdminTable> </AdminTable>
{!loading && filteredItems.length > 0 ? (
<div className="card-footer py-2 small text-secondary d-flex flex-wrap gap-3">
<span>
Directories:{' '}
<strong>{filteredItems.filter((i) => i.is_dir).length}</strong>
</span>
<span>
Files:{' '}
<strong>{filteredItems.filter((i) => !i.is_dir).length}</strong>
</span>
<span>
Showing <strong>{filteredItems.length}</strong> of <strong>{items.length}</strong> in folder
</span>
</div>
) : null}
</>
)} )}
</div> </div>
{clipboard && clipboard.entries.length > 0 ? (
<div className="alert alert-light border mt-3 mb-0 small py-2">
<i className="ti ti-clipboard me-1" aria-hidden />
Clipboard: <strong>{clipboard.op}</strong> ({clipboard.entries.length} item) use <strong>Paste</strong> in the target folder.
</div>
) : null}
</> </>
) )
} }