Spaces:
Runtime error
Runtime error
Create panel.py
Browse files
panel.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, asyncio, collections, shutil, urllib.request, json
|
| 2 |
+
from fastapi import FastAPI, WebSocket, Form, UploadFile, File, HTTPException
|
| 3 |
+
from fastapi.responses import HTMLResponse, Response, FileResponse
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
import uvicorn
|
| 6 |
+
|
| 7 |
+
app = FastAPI()
|
| 8 |
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
| 9 |
+
|
| 10 |
+
BASE_DIR = os.environ.get("SERVER_DIR", os.path.abspath("/app"))
|
| 11 |
+
mc_process = None
|
| 12 |
+
output_history = collections.deque(maxlen=300)
|
| 13 |
+
connected_clients = set()
|
| 14 |
+
|
| 15 |
+
HTML_CONTENT = """
|
| 16 |
+
<!DOCTYPE html><html lang="en" class="dark"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
| 17 |
+
<title>Server Engine</title><script src="https://cdn.tailwindcss.com"></script><script src="https://unpkg.com/lucide@latest"></script>
|
| 18 |
+
<style>
|
| 19 |
+
:root{--bg:#000;--panel:#0a0a0a;--border:#1a1a1a;--accent:#22c55e;}
|
| 20 |
+
body{background:var(--bg);color:#a1a1aa;font-family:ui-sans-serif,system-ui,sans-serif;margin:0;height:100vh;display:flex;flex-direction:column;overflow:hidden;}
|
| 21 |
+
::-webkit-scrollbar{width:4px;height:4px;} ::-webkit-scrollbar-thumb{background:#27272a;border-radius:4px;} ::-webkit-scrollbar-thumb:hover{background:var(--accent);}
|
| 22 |
+
.tab-content{display:none;position:absolute;inset:0;padding:8px;} .tab-content.active{display:flex;flex-direction:column;}
|
| 23 |
+
.nav-btn{color:#52525b;transition:.2s;} .nav-btn:hover,.nav-btn.active{color:var(--accent);}
|
| 24 |
+
.log-line{word-break:break-all;padding:0.5px 0;font-family:monospace;font-size:11px;}
|
| 25 |
+
.modal{display:none;position:fixed;inset:0;background:#000a;backdrop-filter:blur(4px);z-index:50;align-items:center;justify-content:center;} .modal.active{display:flex;}
|
| 26 |
+
input:focus,textarea:focus{outline:none;border-color:var(--accent);}
|
| 27 |
+
</style></head>
|
| 28 |
+
<body>
|
| 29 |
+
<div class="flex flex-1 overflow-hidden">
|
| 30 |
+
<!-- Sidebar -->
|
| 31 |
+
<aside class="w-12 bg-[#050505] border-r border-[#1a1a1a] flex flex-col items-center py-6 gap-8 z-40 shrink-0 hidden sm:flex">
|
| 32 |
+
<div class="text-green-500 drop-shadow-[0_0_8px_rgba(34,197,94,0.4)]"><i data-lucide="server" class="w-5 h-5"></i></div>
|
| 33 |
+
<nav class="flex flex-col gap-6 items-center">
|
| 34 |
+
<button onclick="switchTab('console')" id="nav-console" class="nav-btn active"><i data-lucide="terminal-square" class="w-5 h-5"></i></button>
|
| 35 |
+
<button onclick="switchTab('files')" id="nav-files" class="nav-btn"><i data-lucide="folder-tree" class="w-5 h-5"></i></button>
|
| 36 |
+
<button onclick="switchTab('config')" id="nav-config" class="nav-btn"><i data-lucide="settings-2" class="w-5 h-5"></i></button>
|
| 37 |
+
<button onclick="switchTab('plugins')" id="nav-plugins" class="nav-btn"><i data-lucide="puzzle" class="w-5 h-5"></i></button>
|
| 38 |
+
</nav>
|
| 39 |
+
</aside>
|
| 40 |
+
|
| 41 |
+
<main class="flex-1 relative bg-black overflow-hidden sm:p-2">
|
| 42 |
+
<!-- CONSOLE -->
|
| 43 |
+
<div id="tab-console" class="tab-content active">
|
| 44 |
+
<div class="flex-1 bg-panel border border-border rounded-xl flex flex-col overflow-hidden shadow-2xl">
|
| 45 |
+
<div class="h-9 border-b border-border bg-[#050505] flex items-center px-3 gap-2 shrink-0">
|
| 46 |
+
<div class="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.8)]"></div><span class="text-[10px] font-mono text-zinc-500">engine-live-stream</span>
|
| 47 |
+
</div>
|
| 48 |
+
<div id="terminal-output" class="flex-1 p-2 overflow-y-auto text-zinc-300"></div>
|
| 49 |
+
<div class="h-11 border-t border-border bg-[#050505] flex items-center px-3 gap-2 shrink-0">
|
| 50 |
+
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-green-500 shrink-0"></i>
|
| 51 |
+
<input type="text" id="cmd-input" class="flex-1 bg-transparent border-none text-green-400 font-mono text-xs" placeholder="Execute command..." autocomplete="off">
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<!-- FILES -->
|
| 57 |
+
<div id="tab-files" class="tab-content">
|
| 58 |
+
<div class="flex-1 bg-panel border border-border rounded-xl flex flex-col overflow-hidden shadow-2xl">
|
| 59 |
+
<div class="bg-[#050505] border-b border-border p-2 flex justify-between items-center gap-2 shrink-0">
|
| 60 |
+
<div id="breadcrumbs" class="flex items-center gap-1 text-[11px] font-mono text-zinc-500 overflow-x-auto"></div>
|
| 61 |
+
<div class="flex items-center gap-1">
|
| 62 |
+
<input type="file" id="file-upload" class="hidden" onchange="uploadFile(event)">
|
| 63 |
+
<button onclick="document.getElementById('file-upload').click()" class="p-1.5 rounded-md text-zinc-500 hover:text-green-500"><i data-lucide="upload" class="w-4 h-4"></i></button>
|
| 64 |
+
<button onclick="loadFiles(currentPath)" class="p-1.5 rounded-md text-zinc-500 hover:text-white"><i data-lucide="rotate-cw" class="w-4 h-4"></i></button>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
<div id="file-list" class="flex-1 overflow-y-auto"></div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<!-- CONFIG -->
|
| 72 |
+
<div id="tab-config" class="tab-content">
|
| 73 |
+
<div class="flex-1 bg-panel border border-border rounded-xl flex flex-col overflow-hidden shadow-2xl">
|
| 74 |
+
<div class="h-10 border-b border-border bg-[#050505] flex items-center justify-between px-3 shrink-0">
|
| 75 |
+
<span class="text-xs font-mono text-zinc-300 flex items-center gap-2"><i data-lucide="sliders" class="w-3.5 h-3.5 text-green-500"></i> server.properties</span>
|
| 76 |
+
<button onclick="saveConfig()" class="bg-green-600 hover:bg-green-500 text-black px-3 py-1 rounded text-[11px] font-bold flex items-center gap-1"><i data-lucide="save" class="w-3 h-3"></i> Apply</button>
|
| 77 |
+
</div>
|
| 78 |
+
<textarea id="config-editor" class="flex-1 bg-transparent p-3 text-zinc-300 font-mono text-[11px] resize-none border-none" spellcheck="false"></textarea>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<!-- PLUGINS -->
|
| 83 |
+
<div id="tab-plugins" class="tab-content">
|
| 84 |
+
<div class="flex-1 bg-panel border border-border rounded-xl flex flex-col overflow-hidden shadow-2xl">
|
| 85 |
+
<div class="bg-[#050505] border-b border-border p-2 flex items-center justify-between shrink-0 gap-2">
|
| 86 |
+
<div class="flex items-center gap-2">
|
| 87 |
+
<span class="text-[10px] text-zinc-500 font-mono">MC Ver:</span>
|
| 88 |
+
<input type="text" id="mc-version" value="1.20.4" class="bg-[#111] border border-[#222] text-zinc-300 text-[11px] px-2 py-1 rounded w-16 text-center font-mono">
|
| 89 |
+
</div>
|
| 90 |
+
<div class="flex bg-[#111] rounded p-0.5">
|
| 91 |
+
<button id="pbtn-installed" onclick="switchPluginView('installed')" class="px-3 py-1 text-[10px] font-bold rounded bg-[#222] text-white">Installed</button>
|
| 92 |
+
<button id="pbtn-browser" onclick="switchPluginView('browser')" class="px-3 py-1 text-[10px] font-bold rounded text-zinc-500 hover:text-white">Browser</button>
|
| 93 |
+
</div>
|
| 94 |
+
<div class="flex items-center gap-2" id="plugin-search-container" style="display:none;">
|
| 95 |
+
<input type="text" id="plugin-search" class="bg-[#111] border border-[#222] text-zinc-300 text-[11px] px-2 py-1 rounded w-32 font-mono" placeholder="Search..." onkeydown="if(event.key==='Enter') searchPlugins()">
|
| 96 |
+
<button onclick="searchPlugins()" class="text-green-500 hover:text-green-400"><i data-lucide="search" class="w-4 h-4"></i></button>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
<div id="plugin-list" class="flex-1 overflow-y-auto p-2 grid grid-cols-1 sm:grid-cols-2 gap-2 content-start"></div>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</main>
|
| 103 |
+
|
| 104 |
+
<!-- Mobile Nav -->
|
| 105 |
+
<nav class="flex sm:hidden bg-[#050505] border-t border-border shrink-0 pb-[env(safe-area-inset-bottom,0)]">
|
| 106 |
+
<button onclick="switchTab('console')" id="mnav-console" class="flex-1 flex flex-col items-center gap-1 py-2 text-[9px] text-zinc-500 nav-btn active"><i data-lucide="terminal-square" class="w-5 h-5"></i> Console</button>
|
| 107 |
+
<button onclick="switchTab('files')" id="mnav-files" class="flex-1 flex flex-col items-center gap-1 py-2 text-[9px] text-zinc-500 nav-btn"><i data-lucide="folder-tree" class="w-5 h-5"></i> Files</button>
|
| 108 |
+
<button onclick="switchTab('config')" id="mnav-config" class="flex-1 flex flex-col items-center gap-1 py-2 text-[9px] text-zinc-500 nav-btn"><i data-lucide="settings-2" class="w-5 h-5"></i> Config</button>
|
| 109 |
+
<button onclick="switchTab('plugins')" id="mnav-plugins" class="flex-1 flex flex-col items-center gap-1 py-2 text-[9px] text-zinc-500 nav-btn"><i data-lucide="puzzle" class="w-5 h-5"></i> Plugins</button>
|
| 110 |
+
</nav>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<!-- Modal -->
|
| 114 |
+
<div id="editor-modal" class="modal">
|
| 115 |
+
<div class="bg-panel border border-[#222] rounded-t-xl sm:rounded-xl w-full max-w-3xl h-[85vh] flex flex-col self-end sm:self-center">
|
| 116 |
+
<div class="p-2 border-b border-border bg-[#050505] flex justify-between items-center shrink-0">
|
| 117 |
+
<span id="editor-title" class="text-[11px] font-mono text-green-400 truncate w-2/3"></span>
|
| 118 |
+
<div class="flex gap-2">
|
| 119 |
+
<button onclick="document.getElementById('editor-modal').classList.remove('active')" class="px-3 py-1 text-[11px] text-zinc-500 hover:text-white">Discard</button>
|
| 120 |
+
<button onclick="saveFile()" class="bg-green-600 hover:bg-green-500 text-black px-3 py-1 text-[11px] font-bold rounded flex items-center gap-1"><i data-lucide="save" class="w-3 h-3"></i> Save</button>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
<textarea id="editor-content" class="flex-1 bg-transparent p-3 text-zinc-300 font-mono text-[11px] resize-none border-none" spellcheck="false"></textarea>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div id="toast-container" class="fixed bottom-16 sm:bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"></div>
|
| 128 |
+
|
| 129 |
+
<script>
|
| 130 |
+
lucide.createIcons();
|
| 131 |
+
const showToast = (msg, type='info') => {
|
| 132 |
+
const c = document.getElementById('toast-container'), t = document.createElement('div');
|
| 133 |
+
const col = type==='error'?'#ef4444':type==='success'?'#22c55e':'#60a5fa';
|
| 134 |
+
t.className = `flex items-center gap-2 bg-[#0a0a0a] border border-[${col}33] p-2.5 rounded-lg shadow-xl text-[11px] font-mono text-zinc-200 transition-all duration-300 translate-y-4 opacity-0`;
|
| 135 |
+
t.innerHTML = `<div class="w-1.5 h-1.5 rounded-full" style="background:${col}"></div>${msg}`;
|
| 136 |
+
c.appendChild(t); requestAnimationFrame(() => { t.classList.remove('translate-y-4','opacity-0'); });
|
| 137 |
+
setTimeout(() => { t.classList.add('opacity-0'); setTimeout(()=>t.remove(),300); }, 3000);
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
// Navigation
|
| 141 |
+
function switchTab(t) {
|
| 142 |
+
document.querySelectorAll('.tab-content').forEach(el=>el.classList.remove('active'));
|
| 143 |
+
document.querySelectorAll('.nav-btn').forEach(el=>el.classList.remove('active'));
|
| 144 |
+
document.getElementById('tab-'+t).classList.add('active');
|
| 145 |
+
if(document.getElementById('nav-'+t)) document.getElementById('nav-'+t).classList.add('active');
|
| 146 |
+
if(document.getElementById('mnav-'+t)) document.getElementById('mnav-'+t).classList.add('active');
|
| 147 |
+
if(t==='files'&&!currentPathLoaded){loadFiles(''); currentPathLoaded=true;}
|
| 148 |
+
if(t==='config') loadConfig();
|
| 149 |
+
if(t==='plugins'&&Object.keys(pluginsJson).length===0) initPlugins();
|
| 150 |
+
if(t==='console') termOut.scrollTop=termOut.scrollHeight;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Console
|
| 154 |
+
const termOut = document.getElementById('terminal-output'), cmdInput = document.getElementById('cmd-input');
|
| 155 |
+
function appendLog(txt) {
|
| 156 |
+
const d = document.createElement('div'); d.className='log-line';
|
| 157 |
+
d.innerHTML = txt.replace(/</g,'<').replace(/>/g,'>').replace(/\\x1b\\[([0-9;]*)m(.*?)(?=\\x1b|$)/gs, (m,g1,g2) => {
|
| 158 |
+
let s=''; if(g1.includes('31'))s='color:#ef4444'; else if(g1.includes('32'))s='color:#22c55e'; else if(g1.includes('33'))s='color:#eab308'; else if(g1.includes('36'))s='color:#06b6d4';
|
| 159 |
+
return `<span style="${s}">${g2}</span>`;
|
| 160 |
+
});
|
| 161 |
+
const bot = termOut.scrollHeight - termOut.clientHeight <= termOut.scrollTop + 10;
|
| 162 |
+
termOut.appendChild(d); if(termOut.childElementCount>300) termOut.removeChild(termOut.firstChild);
|
| 163 |
+
if(bot) termOut.scrollTop = termOut.scrollHeight;
|
| 164 |
+
}
|
| 165 |
+
const ws = new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
| 166 |
+
ws.onmessage = e => appendLog(e.data);
|
| 167 |
+
cmdInput.onkeypress = e => { if(e.key==='Enter'&&cmdInput.value.trim()){ ws.send(cmdInput.value.trim()); cmdInput.value=''; } };
|
| 168 |
+
|
| 169 |
+
// Files
|
| 170 |
+
let currentPath = '', currentPathLoaded = false, currentEditPath = '';
|
| 171 |
+
async function loadFiles(p) {
|
| 172 |
+
currentPath = p; document.getElementById('breadcrumbs').innerHTML = `<button onclick="loadFiles('')" class="hover:text-green-500"><i data-lucide="home" class="w-3.5 h-3.5"></i></button> ` + p.split('/').filter(x=>x).map((x,i,a)=>`<span class="opacity-30 mx-1">/</span><span class="${i===a.length-1?'text-green-500':''}">${x}</span>`).join('');
|
| 173 |
+
lucide.createIcons(); document.getElementById('file-list').innerHTML='<div class="p-4 text-center text-xs text-zinc-500 font-mono">Loading...</div>';
|
| 174 |
+
try {
|
| 175 |
+
const res = await fetch(`/api/fs/list?path=${encodeURIComponent(p)}`); const files = await res.json();
|
| 176 |
+
let html = p?`<div class="flex items-center p-2 border-b border-[#1a1a1a] cursor-pointer hover:bg-[#111]" onclick="loadFiles('${p.split('/').slice(0,-1).join('/')}')"><i data-lucide="corner-left-up" class="w-4 h-4 text-zinc-500 mr-2"></i><span class="text-[11px] font-mono text-zinc-500">..</span></div>`:'';
|
| 177 |
+
if(!files.length&&!p) html+='<div class="p-4 text-center text-xs text-zinc-500 font-mono">Empty Directory</div>';
|
| 178 |
+
files.forEach(f => {
|
| 179 |
+
const ic = f.is_dir ? '<i data-lucide="folder" class="w-4 h-4 text-green-500 shrink-0"></i>' : '<i data-lucide="file" class="w-4 h-4 text-zinc-500 shrink-0"></i>';
|
| 180 |
+
const pth = p?`${p}/${f.name}`:f.name;
|
| 181 |
+
html += `<div class="flex items-center p-2 border-b border-[#1a1a1a] cursor-pointer hover:bg-[#111] gap-2" onclick="${f.is_dir?`loadFiles('${pth}')`:`editFile('${pth}')`}">${ic}<span class="text-[11px] font-mono text-zinc-300 flex-1 truncate">${f.name}</span>
|
| 182 |
+
<button onclick="event.stopPropagation(); deleteFile('${pth}')" class="p-1 hover:text-red-500 text-zinc-600"><i data-lucide="trash" class="w-3.5 h-3.5"></i></button></div>`;
|
| 183 |
+
});
|
| 184 |
+
document.getElementById('file-list').innerHTML = html; lucide.createIcons();
|
| 185 |
+
} catch { showToast('Load failed', 'error'); }
|
| 186 |
+
}
|
| 187 |
+
async function deleteFile(p) {
|
| 188 |
+
if(!confirm(`Delete ${p}?`)) return;
|
| 189 |
+
const fd=new FormData(); fd.append('path',p);
|
| 190 |
+
if((await fetch('/api/fs/delete',{method:'POST',body:fd})).ok) { showToast('Deleted','success'); loadFiles(currentPath); }
|
| 191 |
+
}
|
| 192 |
+
async function uploadFile(e) {
|
| 193 |
+
if(!e.target.files[0]) return;
|
| 194 |
+
const fd=new FormData(); fd.append('path',currentPath); fd.append('file',e.target.files[0]);
|
| 195 |
+
showToast('Uploading...'); if((await fetch('/api/fs/upload',{method:'POST',body:fd})).ok) { showToast('Uploaded','success'); loadFiles(currentPath); }
|
| 196 |
+
e.target.value='';
|
| 197 |
+
}
|
| 198 |
+
async function editFile(p) {
|
| 199 |
+
try {
|
| 200 |
+
const r=await fetch(`/api/fs/read?path=${encodeURIComponent(p)}`); if(!r.ok) throw new Error();
|
| 201 |
+
currentEditPath=p; document.getElementById('editor-title').innerText=p; document.getElementById('editor-content').value=await r.text();
|
| 202 |
+
document.getElementById('editor-modal').classList.add('active');
|
| 203 |
+
} catch { showToast('Cannot read file','error'); }
|
| 204 |
+
}
|
| 205 |
+
async function saveFile() {
|
| 206 |
+
const fd=new FormData(); fd.append('path',currentEditPath); fd.append('content',document.getElementById('editor-content').value);
|
| 207 |
+
if((await fetch('/api/fs/write',{method:'POST',body:fd})).ok) { showToast('Saved','success'); document.getElementById('editor-modal').classList.remove('active'); }
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// Config
|
| 211 |
+
async function loadConfig() { try{ document.getElementById('config-editor').value = await(await fetch('/api/fs/read?path=server.properties')).text(); }catch{} }
|
| 212 |
+
async function saveConfig() {
|
| 213 |
+
const fd=new FormData(); fd.append('path','server.properties'); fd.append('content',document.getElementById('config-editor').value);
|
| 214 |
+
if((await fetch('/api/fs/write',{method:'POST',body:fd})).ok) showToast('Config applied','success');
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// Plugins (Modrinth)
|
| 218 |
+
let pluginsJson = {}, currentPView = 'installed';
|
| 219 |
+
async function initPlugins() {
|
| 220 |
+
try { const r = await fetch('/api/fs/read?path=plugins/plugins.json'); if(r.ok) pluginsJson = JSON.parse(await r.text()); } catch{}
|
| 221 |
+
renderInstalledPlugins(); checkUpdates();
|
| 222 |
+
}
|
| 223 |
+
async function savePluginsState() {
|
| 224 |
+
const fd=new FormData(); fd.append('path','plugins/plugins.json'); fd.append('content',JSON.stringify(pluginsJson,null,2));
|
| 225 |
+
await fetch('/api/fs/write',{method:'POST',body:fd});
|
| 226 |
+
}
|
| 227 |
+
function switchPluginView(v) {
|
| 228 |
+
currentPView = v; document.getElementById('pbtn-installed').className = `px-3 py-1 text-[10px] font-bold rounded ${v==='installed'?'bg-[#222] text-white':'text-zinc-500 hover:text-white'}`;
|
| 229 |
+
document.getElementById('pbtn-browser').className = `px-3 py-1 text-[10px] font-bold rounded ${v==='browser'?'bg-[#222] text-white':'text-zinc-500 hover:text-white'}`;
|
| 230 |
+
document.getElementById('plugin-search-container').style.display = v==='browser'?'flex':'none';
|
| 231 |
+
v==='installed' ? renderInstalledPlugins() : document.getElementById('plugin-list').innerHTML='<div class="col-span-full p-4 text-center text-xs text-zinc-500 font-mono">Search above to find plugins.</div>';
|
| 232 |
+
}
|
| 233 |
+
function renderInstalledPlugins() {
|
| 234 |
+
const c = document.getElementById('plugin-list'); c.innerHTML='';
|
| 235 |
+
const keys = Object.keys(pluginsJson); if(!keys.length) return c.innerHTML='<div class="col-span-full p-4 text-center text-xs text-zinc-500 font-mono">No plugins installed via engine.</div>';
|
| 236 |
+
keys.forEach(k => {
|
| 237 |
+
const p = pluginsJson[k];
|
| 238 |
+
c.innerHTML += `<div class="bg-[#050505] border border-border rounded p-3 flex flex-col gap-2">
|
| 239 |
+
<div class="flex justify-between items-start">
|
| 240 |
+
<div><h3 class="text-[12px] font-bold text-white">${p.name||k}</h3><p class="text-[10px] font-mono text-zinc-500 break-all">${p.filename}</p></div>
|
| 241 |
+
${p.has_update ? `<button onclick="installPlugin('${k}', true)" class="bg-blue-600 hover:bg-blue-500 text-white px-2 py-1 rounded text-[10px] font-bold">Update</button>` : `<span class="bg-[#111] px-2 py-0.5 rounded text-[10px] text-zinc-500 border border-[#222]">Up to date</span>`}
|
| 242 |
+
</div>
|
| 243 |
+
</div>`;
|
| 244 |
+
});
|
| 245 |
+
}
|
| 246 |
+
async function searchPlugins() {
|
| 247 |
+
const q = document.getElementById('plugin-search').value.trim(); const v = document.getElementById('mc-version').value.trim(); if(!q||!v)return;
|
| 248 |
+
document.getElementById('plugin-list').innerHTML='<div class="col-span-full p-4 text-center text-xs text-zinc-500 font-mono">Searching Modrinth...</div>';
|
| 249 |
+
try {
|
| 250 |
+
const res = await fetch(`https://api.modrinth.com/v2/search?query=${q}&facets=[["project_type:plugin"],["versions:${v}"]]`).then(r=>r.json());
|
| 251 |
+
const c = document.getElementById('plugin-list'); c.innerHTML='';
|
| 252 |
+
if(!res.hits.length) return c.innerHTML='<div class="col-span-full p-4 text-center text-xs text-zinc-500 font-mono">No results found for this version.</div>';
|
| 253 |
+
res.hits.forEach(h => {
|
| 254 |
+
c.innerHTML += `<div class="bg-[#050505] border border-border rounded p-3 flex flex-col justify-between gap-3">
|
| 255 |
+
<div><h3 class="text-[12px] font-bold text-green-400 mb-1">${h.title}</h3><p class="text-[10px] text-zinc-400 leading-snug">${h.description}</p></div>
|
| 256 |
+
<button onclick="installPlugin('${h.project_id}')" class="bg-[#1a1a1a] hover:bg-green-600 hover:text-black transition-colors text-white px-3 py-1.5 rounded text-[10px] font-bold w-full">Install Latest</button>
|
| 257 |
+
</div>`;
|
| 258 |
+
});
|
| 259 |
+
} catch { showToast('Search failed','error'); }
|
| 260 |
+
}
|
| 261 |
+
async function checkUpdates() {
|
| 262 |
+
const v = document.getElementById('mc-version').value.trim(); if(!v) return;
|
| 263 |
+
for (let pid in pluginsJson) {
|
| 264 |
+
try {
|
| 265 |
+
const res = await fetch(`https://api.modrinth.com/v2/project/${pid}/version?game_versions=["${v}"]`).then(r=>r.json());
|
| 266 |
+
if(res.length && res[0].id !== pluginsJson[pid].version_id) pluginsJson[pid].has_update = true;
|
| 267 |
+
else pluginsJson[pid].has_update = false;
|
| 268 |
+
} catch{}
|
| 269 |
+
}
|
| 270 |
+
if(currentPView==='installed') renderInstalledPlugins();
|
| 271 |
+
}
|
| 272 |
+
async function installPlugin(pid, isUpdate=false) {
|
| 273 |
+
showToast(isUpdate?'Updating...':'Installing...');
|
| 274 |
+
const v = document.getElementById('mc-version').value.trim();
|
| 275 |
+
try {
|
| 276 |
+
const res = await fetch(`https://api.modrinth.com/v2/project/${pid}/version?game_versions=["${v}"]`).then(r=>r.json());
|
| 277 |
+
if(!res.length) return showToast('No compatible version found', 'error');
|
| 278 |
+
const file = res[0].files.find(f=>f.primary) || res[0].files[0];
|
| 279 |
+
const fd = new FormData(); fd.append('url', file.url); fd.append('filename', file.filename);
|
| 280 |
+
if((await fetch('/api/plugins/install', {method:'POST', body:fd})).ok) {
|
| 281 |
+
const proj = await fetch(`https://api.modrinth.com/v2/project/${pid}`).then(r=>r.json());
|
| 282 |
+
pluginsJson[pid] = { project_id: pid, version_id: res[0].id, filename: file.filename, name: proj.title };
|
| 283 |
+
await savePluginsState(); showToast('Success', 'success'); if(currentPView==='installed') renderInstalledPlugins();
|
| 284 |
+
} else showToast('Download failed', 'error');
|
| 285 |
+
} catch { showToast('Network error', 'error'); }
|
| 286 |
+
}
|
| 287 |
+
</script></body></html>
|
| 288 |
+
"""
|
| 289 |
+
|
| 290 |
+
def get_safe_path(subpath: str):
|
| 291 |
+
p = os.path.abspath(os.path.join(BASE_DIR, (subpath or "").strip("/")))
|
| 292 |
+
if not p.startswith(BASE_DIR): raise HTTPException(403, "Access denied")
|
| 293 |
+
return p
|
| 294 |
+
|
| 295 |
+
async def read_stream(stream, prefix=""):
|
| 296 |
+
while True:
|
| 297 |
+
try:
|
| 298 |
+
line = await stream.readline()
|
| 299 |
+
if not line: break
|
| 300 |
+
await broadcast(prefix + line.decode('utf-8', errors='replace').rstrip('\r\n'))
|
| 301 |
+
except: break
|
| 302 |
+
|
| 303 |
+
async def broadcast(msg: str):
|
| 304 |
+
output_history.append(msg)
|
| 305 |
+
for c in list(connected_clients):
|
| 306 |
+
try: await c.send_text(msg)
|
| 307 |
+
except: connected_clients.remove(c)
|
| 308 |
+
|
| 309 |
+
async def start_minecraft():
|
| 310 |
+
global mc_process
|
| 311 |
+
jar_path = os.path.join(BASE_DIR, "purpur.jar")
|
| 312 |
+
if not os.path.exists(jar_path): await broadcast("\x1b[33m[Panel] Missing purpur.jar in app directory.\x1b[0m")
|
| 313 |
+
mc_process = await asyncio.create_subprocess_exec(
|
| 314 |
+
"java", "-Xmx4G", "-Dfile.encoding=UTF-8", "-jar", "purpur.jar", "--nogui",
|
| 315 |
+
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=BASE_DIR
|
| 316 |
+
)
|
| 317 |
+
asyncio.create_task(read_stream(mc_process.stdout))
|
| 318 |
+
|
| 319 |
+
@app.on_event("startup")
|
| 320 |
+
async def startup_event():
|
| 321 |
+
os.makedirs(BASE_DIR, exist_ok=True)
|
| 322 |
+
asyncio.create_task(start_minecraft())
|
| 323 |
+
|
| 324 |
+
@app.get("/")
|
| 325 |
+
def get_panel(): return HTMLResponse(content=HTML_CONTENT)
|
| 326 |
+
|
| 327 |
+
@app.websocket("/ws")
|
| 328 |
+
async def websocket_endpoint(ws: WebSocket):
|
| 329 |
+
await ws.accept(); connected_clients.add(ws)
|
| 330 |
+
for l in output_history: await ws.send_text(l)
|
| 331 |
+
try:
|
| 332 |
+
while True:
|
| 333 |
+
cmd = await ws.receive_text()
|
| 334 |
+
if mc_process and mc_process.stdin: mc_process.stdin.write((cmd + "\n").encode()); await mc_process.stdin.drain()
|
| 335 |
+
except: connected_clients.discard(ws)
|
| 336 |
+
|
| 337 |
+
@app.get("/api/fs/list")
|
| 338 |
+
def fs_list(path: str = ""):
|
| 339 |
+
t = get_safe_path(path)
|
| 340 |
+
if not os.path.exists(t): return []
|
| 341 |
+
return sorted([{"name": f, "is_dir": os.path.isdir(os.path.join(t, f))} for f in os.listdir(t)], key=lambda x: (not x["is_dir"], x["name"].lower()))
|
| 342 |
+
|
| 343 |
+
@app.get("/api/fs/read")
|
| 344 |
+
def fs_read(path: str):
|
| 345 |
+
try:
|
| 346 |
+
with open(get_safe_path(path), 'r', encoding='utf-8') as f: return Response(content=f.read(), media_type="text/plain")
|
| 347 |
+
except: raise HTTPException(400, "File is binary or unreadable")
|
| 348 |
+
|
| 349 |
+
@app.post("/api/fs/write")
|
| 350 |
+
def fs_write(path: str = Form(...), content: str = Form(...)):
|
| 351 |
+
t = get_safe_path(path); os.makedirs(os.path.dirname(t), exist_ok=True)
|
| 352 |
+
with open(t, 'w', encoding='utf-8') as f: f.write(content)
|
| 353 |
+
return {"status": "ok"}
|
| 354 |
+
|
| 355 |
+
@app.post("/api/fs/upload")
|
| 356 |
+
async def fs_upload(path: str = Form(""), file: UploadFile = File(...)):
|
| 357 |
+
d = get_safe_path(path); os.makedirs(d, exist_ok=True)
|
| 358 |
+
with open(os.path.join(d, file.filename), "wb") as b: shutil.copyfileobj(file.file, b)
|
| 359 |
+
return {"status": "ok"}
|
| 360 |
+
|
| 361 |
+
@app.post("/api/fs/delete")
|
| 362 |
+
def fs_delete(path: str = Form(...)):
|
| 363 |
+
t = get_safe_path(path)
|
| 364 |
+
if os.path.isdir(t): shutil.rmtree(t)
|
| 365 |
+
else: os.remove(t)
|
| 366 |
+
return {"status": "ok"}
|
| 367 |
+
|
| 368 |
+
@app.post("/api/plugins/install")
|
| 369 |
+
def install_plugin(url: str = Form(...), filename: str = Form(...)):
|
| 370 |
+
try:
|
| 371 |
+
pd = get_safe_path("plugins")
|
| 372 |
+
os.makedirs(pd, exist_ok=True)
|
| 373 |
+
req = urllib.request.Request(url, headers={'User-Agent': 'HF-Minecraft-Panel/1.0'})
|
| 374 |
+
with urllib.request.urlopen(req) as response, open(os.path.join(pd, filename), 'wb') as out_file:
|
| 375 |
+
shutil.copyfileobj(response, out_file)
|
| 376 |
+
return {"status": "ok"}
|
| 377 |
+
except Exception as e:
|
| 378 |
+
raise HTTPException(400, str(e))
|
| 379 |
+
|
| 380 |
+
if __name__ == "__main__":
|
| 381 |
+
port = int(os.environ.get("PORT", 7860))
|
| 382 |
+
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
|