OrbitMC commited on
Commit
b9d9774
·
verified ·
1 Parent(s): 44c040e

Update panel.py

Browse files
Files changed (1) hide show
  1. panel.py +416 -466
panel.py CHANGED
@@ -13,462 +13,429 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
13
  mc_process = None
14
  output_history = collections.deque(maxlen=300)
15
  connected_clients = set()
16
- # Automatically adapts to Hugging Face Docker environments (/app, /home/user/app, etc.)
17
- BASE_DIR = os.path.abspath(os.getcwd())
18
 
19
  # -----------------
20
  # HTML FRONTEND (Ultra-Modern UI)
21
  # -----------------
22
- HTML_CONTENT = """<!DOCTYPE html>
23
- <html lang="en">
 
24
  <head>
25
- <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
26
- <title>OrbitMC</title>
27
- <link rel="preconnect" href="https://fonts.googleapis.com">
28
- <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Geist:wght@300;400;500;600&display=swap" rel="stylesheet">
29
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
30
- <style>
31
- *{box-sizing:border-box;margin:0;padding:0}
32
- :root{
33
- --bg:#0a0a0a;--s1:#111;--s2:#181818;--s3:#222;
34
- --b1:#2a2a2a;--b2:#333;
35
- --t1:#f0f0f0;--t2:#999;--t3:#555;
36
- --accent:#4ade80;--accent2:#22c55e;
37
- --red:#f87171;--blue:#60a5fa;--yellow:#fbbf24;
38
- --r:8px;--font:'Geist',sans-serif;--mono:'JetBrains Mono',monospace;
39
- --trans:all .15s ease;
40
- }
41
- body{background:var(--bg);color:var(--t1);font-family:var(--font);font-size:14px;display:flex;height:100vh;overflow:hidden}
42
-
43
- /* SIDEBAR */
44
- .sidebar{width:56px;background:var(--s1);border-right:1px solid var(--b1);display:flex;flex-direction:column;align-items:center;padding:16px 0;gap:4px;z-index:10;flex-shrink:0}
45
- .nav-btn{width:40px;height:40px;border:none;background:transparent;color:var(--t3);border-radius:var(--r);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:var(--trans);position:relative}
46
- .nav-btn:hover{background:var(--s3);color:var(--t2)}
47
- .nav-btn.active{background:rgba(74,222,128,.12);color:var(--accent)}
48
- .nav-btn .tooltip{position:absolute;left:52px;background:#1a1a1a;border:1px solid var(--b1);color:var(--t1);padding:4px 10px;border-radius:6px;font-size:12px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .15s;z-index:100}
49
- .nav-btn:hover .tooltip{opacity:1}
50
-
51
- /* MAIN */
52
- .main{flex:1;display:flex;flex-direction:column;overflow:hidden}
53
- .panel{display:none;flex:1;overflow:hidden}
54
- .panel.active{display:flex;flex-direction:column}
55
-
56
- /* CONSOLE */
57
- .console-wrap{flex:1;position:relative;overflow:hidden;background:var(--s1)}
58
- .console-blur{position:absolute;top:0;left:0;right:0;height:60px;background:linear-gradient(to bottom,var(--s1) 0%,transparent 100%);z-index:2;pointer-events:none}
59
- .console-out{position:absolute;inset:0;overflow-y:auto;padding:16px;font-family:var(--mono);font-size:12.5px;line-height:1.7;scrollbar-width:thin;scrollbar-color:var(--b2) transparent}
60
- .console-out::-webkit-scrollbar{width:4px}
61
- .console-out::-webkit-scrollbar-thumb{background:var(--b2);border-radius:2px}
62
- .log-line{animation:fadeUp .25s ease forwards;opacity:0;word-break:break-all}
63
- @keyframes fadeUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
64
- .log-line.info{color:#94a3b8}.log-line.warn{color:var(--yellow)}.log-line.error{color:var(--red)}.log-line.ok{color:var(--accent)}
65
- .console-input-bar{padding:12px 16px 20px;background:var(--s1);border-top:1px solid var(--b1);display:flex;gap:8px;align-items:center}
66
- .console-input-bar .prompt{color:var(--accent);font-family:var(--mono);font-size:13px;flex-shrink:0}
67
- .console-input-bar input{flex:1;background:var(--s2);border:1px solid var(--b1);color:var(--t1);font-family:var(--mono);font-size:13px;padding:8px 12px;border-radius:var(--r);outline:none;transition:var(--trans)}
68
- .console-input-bar input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(74,222,128,.1)}
69
- .send-btn{background:var(--accent);color:#000;border:none;padding:8px 16px;border-radius:var(--r);font-family:var(--font);font-weight:600;font-size:13px;cursor:pointer;transition:var(--trans);flex-shrink:0}
70
- .send-btn:hover{background:var(--accent2)}
71
-
72
- /* FILE MANAGER */
73
- .fm-wrap{display:flex;flex-direction:column;flex:1;overflow:hidden}
74
- .fm-toolbar{padding:12px 16px;background:var(--s1);border-bottom:1px solid var(--b1);display:flex;align-items:center;gap:8px;flex-wrap:wrap}
75
- .fm-breadcrumb{flex:1;display:flex;align-items:center;gap:4px;font-size:13px;color:var(--t2);overflow:hidden;min-width:0}
76
- .fm-breadcrumb span{cursor:pointer;transition:color .15s;white-space:nowrap}
77
- .fm-breadcrumb span:hover{color:var(--accent)}
78
- .fm-breadcrumb .sep{color:var(--t3)}
79
- .tb-btn{height:32px;padding:0 12px;background:var(--s2);border:1px solid var(--b1);color:var(--t2);border-radius:6px;cursor:pointer;font-size:12px;font-family:var(--font);display:flex;align-items:center;gap:6px;transition:var(--trans);white-space:nowrap}
80
- .tb-btn:hover{background:var(--s3);color:var(--t1)}
81
- .tb-btn.danger:hover{border-color:var(--red);color:var(--red)}
82
- .fm-list{flex:1;overflow-y:auto;padding:8px;scrollbar-width:thin;scrollbar-color:var(--b2) transparent}
83
- .fm-list::-webkit-scrollbar{width:4px}
84
- .fm-list::-webkit-scrollbar-thumb{background:var(--b2)}
85
- .fm-empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--t3);font-size:13px}
86
- .fm-item{display:flex;align-items:center;padding:9px 12px;border-radius:6px;cursor:pointer;transition:background .1s;gap:10px;user-select:none}
87
- .fm-item:hover{background:var(--s2)}
88
- .fm-item.selected{background:rgba(74,222,128,.08);outline:1px solid rgba(74,222,128,.2)}
89
- .fm-icon{width:20px;text-align:center;font-size:14px;flex-shrink:0}
90
- .fi-dir{color:var(--blue)}.fi-cfg{color:var(--yellow)}.fi-jar{color:var(--accent)}.fi-log{color:var(--t3)}.fi-other{color:var(--t2)}
91
- .fm-name{flex:1;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
92
- .fm-size{font-size:11px;color:var(--t3);flex-shrink:0}
93
- .ctx-menu{position:fixed;background:#1a1a1a;border:1px solid var(--b1);border-radius:8px;padding:6px;z-index:1000;min-width:160px;box-shadow:0 8px 32px rgba(0,0,0,.6);animation:ctxIn .12s ease}
94
- @keyframes ctxIn{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:none}}
95
- .ctx-item{padding:7px 12px;border-radius:5px;cursor:pointer;font-size:13px;color:var(--t2);display:flex;align-items:center;gap:8px;transition:var(--trans)}
96
- .ctx-item:hover{background:var(--s3);color:var(--t1)}
97
- .ctx-item.danger{color:var(--red)}.ctx-item.danger:hover{background:rgba(248,113,113,.1)}
98
- .ctx-sep{height:1px;background:var(--b1);margin:4px 0}
99
-
100
- /* CONFIG */
101
- .cfg-wrap{flex:1;overflow-y:auto;padding:16px;scrollbar-width:thin;scrollbar-color:var(--b2) transparent}
102
- .cfg-section{background:var(--s1);border:1px solid var(--b1);border-radius:10px;margin-bottom:16px;overflow:hidden}
103
- .cfg-section-head{padding:12px 16px;border-bottom:1px solid var(--b1);font-size:12px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:.06em}
104
- .cfg-row{display:flex;align-items:center;padding:10px 16px;border-bottom:1px solid rgba(255,255,255,.03);gap:12px}
105
- .cfg-row:last-child{border-bottom:none}
106
- .cfg-key{flex:1;font-family:var(--mono);font-size:12.5px;color:var(--t2)}
107
- .cfg-val{flex:1;background:var(--s2);border:1px solid var(--b1);color:var(--t1);font-family:var(--mono);font-size:12px;padding:5px 10px;border-radius:6px;outline:none;transition:var(--trans)}
108
- .cfg-val:focus{border-color:var(--accent)}
109
- .cfg-save{margin:0 16px 16px;background:var(--accent);color:#000;border:none;padding:9px 20px;border-radius:var(--r);font-weight:600;font-size:13px;cursor:pointer;transition:var(--trans)}
110
- .cfg-save:hover{background:var(--accent2)}
111
- .coming-soon{display:flex;align-items:center;justify-content:center;height:100%;flex-direction:column;gap:12px;color:var(--t3)}
112
- .coming-soon i{font-size:36px;opacity:.3}
113
-
114
- /* MODALS */
115
- .overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;display:flex;align-items:center;justify-content:center;animation:fadeIn .15s ease;backdrop-filter:blur(4px)}
116
- @keyframes fadeIn{from{opacity:0}to{opacity:1}}
117
- .modal{background:#161616;border:1px solid var(--b1);border-radius:12px;padding:24px;width:90%;max-width:480px;animation:modalIn .2s ease;box-shadow:0 24px 64px rgba(0,0,0,.6)}
118
- .modal.wide{max-width:760px}
119
- @keyframes modalIn{from{opacity:0;transform:translateY(12px) scale(.98)}to{opacity:1;transform:none}}
120
- .modal h3{font-size:15px;font-weight:600;margin-bottom:16px;color:var(--t1)}
121
- .modal input,.modal textarea{width:100%;background:var(--s2);border:1px solid var(--b1);color:var(--t1);font-family:var(--mono);font-size:13px;padding:9px 12px;border-radius:var(--r);outline:none;transition:var(--trans);margin-bottom:12px}
122
- .modal input:focus,.modal textarea:focus{border-color:var(--accent)}
123
- .modal textarea{min-height:320px;resize:vertical;line-height:1.6}
124
- .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
125
- .btn-ghost{background:transparent;border:1px solid var(--b1);color:var(--t2);padding:7px 16px;border-radius:6px;cursor:pointer;font-family:var(--font);font-size:13px;transition:var(--trans)}
126
- .btn-ghost:hover{border-color:var(--b2);color:var(--t1)}
127
- .btn-primary{background:var(--accent);color:#000;border:none;padding:7px 16px;border-radius:6px;font-family:var(--font);font-weight:600;font-size:13px;cursor:pointer;transition:var(--trans)}
128
- .btn-primary:hover{background:var(--accent2)}
129
- .btn-danger{background:transparent;border:1px solid var(--red);color:var(--red);padding:7px 16px;border-radius:6px;cursor:pointer;font-family:var(--font);font-size:13px;transition:var(--trans)}
130
- .btn-danger:hover{background:rgba(248,113,113,.1)}
131
- .upload-zone{border:2px dashed var(--b2);border-radius:8px;padding:32px;text-align:center;color:var(--t3);cursor:pointer;transition:var(--trans);margin-bottom:12px}
132
- .upload-zone:hover,.upload-zone.drag{border-color:var(--accent);color:var(--accent);background:rgba(74,222,128,.04)}
133
- .upload-zone i{font-size:24px;margin-bottom:8px;display:block}
134
- .upload-zone p{font-size:13px}
135
- .status-dot{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 6px var(--accent);display:inline-block;margin-right:6px;animation:pulse 2s infinite}
136
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
137
- @media(max-width:600px){.sidebar{width:48px}.tb-btn span{display:none}.tb-btn{padding:0 10px}.fm-size{display:none}}
138
- </style>
139
  </head>
140
- <body>
141
- <nav class="sidebar">
142
- <button class="nav-btn active" data-tab="console" onclick="switchTab('console',this)"><i class="fa-solid fa-terminal"></i><span class="tooltip">Console</span></button>
143
- <button class="nav-btn" data-tab="files" onclick="switchTab('files',this)"><i class="fa-solid fa-folder-open"></i><span class="tooltip">Files</span></button>
144
- <button class="nav-btn" data-tab="config" onclick="switchTab('config',this)"><i class="fa-solid fa-sliders"></i><span class="tooltip">Config</span></button>
145
- <button class="nav-btn" data-tab="plugins" onclick="switchTab('plugins',this)" style="margin-top:auto"><i class="fa-solid fa-puzzle-piece"></i><span class="tooltip">Plugins</span></button>
146
- </nav>
147
-
148
- <div class="main">
149
- <!-- CONSOLE -->
150
- <div class="panel active" id="tab-console">
151
- <div class="console-wrap">
152
- <div class="console-blur"></div>
153
- <div class="console-out" id="console-out"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  </div>
155
- <div class="console-input-bar">
156
- <span class="prompt">$</span>
157
- <input id="cmd-input" type="text" placeholder="Enter command..." autocomplete="off" spellcheck="false" onkeydown="cmdKey(event)">
158
- <button class="send-btn" onclick="sendCmd()"><i class="fa-solid fa-paper-plane"></i></button>
159
- </div>
160
- </div>
161
-
162
- <!-- FILES -->
163
- <div class="panel" id="tab-files">
164
- <div class="fm-toolbar">
165
- <div class="fm-breadcrumb" id="breadcrumb"></div>
166
- <button class="tb-btn" onclick="showMkdir()"><i class="fa-solid fa-folder-plus"></i><span>New Folder</span></button>
167
- <button class="tb-btn" onclick="showUpload()"><i class="fa-solid fa-upload"></i><span>Upload</span></button>
168
- </div>
169
- <div class="fm-list" id="fm-list"></div>
170
- </div>
171
 
172
- <!-- CONFIG -->
173
- <div class="panel" id="tab-config">
174
- <div class="cfg-wrap" id="cfg-wrap">
175
- <div class="fm-empty" style="height:80px"><i class="fa-solid fa-spinner fa-spin"></i>&nbsp;Loading...</div>
176
- </div>
177
- </div>
178
-
179
- <!-- PLUGINS -->
180
- <div class="panel" id="tab-plugins">
181
- <div class="coming-soon">
182
- <i class="fa-solid fa-puzzle-piece"></i>
183
- <p style="font-size:15px;font-weight:600;color:var(--t2)">Plugins</p>
184
- <p style="font-size:13px">Coming soon</p>
185
- </div>
186
- </div>
187
- </div>
188
-
189
- <!-- MODALS -->
190
- <div id="overlay" class="overlay" style="display:none" onclick="closeModal(event)">
191
- <div class="modal" id="modal" onclick="event.stopPropagation()">
192
- <h3 id="modal-title"></h3>
193
- <div id="modal-body"></div>
194
- <div class="modal-actions" id="modal-actions"></div>
195
- </div>
196
- </div>
197
-
198
- <script>
199
- // WS
200
- const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
201
- let ws, cmdHistory=[], histIdx=-1;
202
- function connectWS(){
203
- ws = new WebSocket(`${wsProto}://${location.host}/ws`);
204
- ws.onmessage = e => addLog(e.data);
205
- ws.onclose = () => setTimeout(connectWS, 2000);
206
- }
207
- connectWS();
208
-
209
- function classify(l){
210
- if(/\[WARN|WARN\]/i.test(l)) return 'warn';
211
- if(/\[ERROR|ERROR\]/i.test(l)||/exception/i.test(l)) return 'error';
212
- if(/Done|started|enabled|loaded/i.test(l)) return 'ok';
213
- return 'info';
214
- }
215
- function addLog(txt){
216
- const out = document.getElementById('console-out');
217
- const atBottom = out.scrollHeight - out.clientHeight - out.scrollTop < 40;
218
- const d = document.createElement('div');
219
- d.className = `log-line ${classify(txt)}`;
220
- d.textContent = txt;
221
- out.appendChild(d);
222
- if(out.children.length > 400) out.removeChild(out.firstChild);
223
- if(atBottom) out.scrollTop = out.scrollHeight;
224
- }
225
- function sendCmd(){
226
- const i = document.getElementById('cmd-input');
227
- const v = i.value.trim();
228
- if(!v || !ws) return;
229
- ws.send(v);
230
- cmdHistory.unshift(v); histIdx=-1;
231
- i.value='';
232
- }
233
- function cmdKey(e){
234
- if(e.key==='Enter') sendCmd();
235
- else if(e.key==='ArrowUp'){histIdx=Math.min(histIdx+1,cmdHistory.length-1);document.getElementById('cmd-input').value=cmdHistory[histIdx]||'';}
236
- else if(e.key==='ArrowDown'){histIdx=Math.max(histIdx-1,-1);document.getElementById('cmd-input').value=histIdx>=0?cmdHistory[histIdx]:'';}
237
- }
238
-
239
- // TABS
240
- function switchTab(id,btn){
241
- document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
242
- document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));
243
- document.getElementById('tab-'+id).classList.add('active');
244
- btn.classList.add('active');
245
- if(id==='files') loadDir(currentPath);
246
- if(id==='config') loadConfig();
247
- }
248
-
249
- // FILE MANAGER
250
- let currentPath='', selectedItem=null;
251
- const api = p => fetch(p).then(r=>r.json());
252
- const apiPost = (p,fd) => fetch(p,{method:'POST',body:fd});
253
-
254
- async function loadDir(path=''){
255
- currentPath = path;
256
- renderBreadcrumb(path);
257
- const list = document.getElementById('fm-list');
258
- list.innerHTML = '<div class="fm-empty"><i class="fa-solid fa-spinner fa-spin"></i>&nbsp;Loading...</div>';
259
- const items = await api(`/api/fs/list?path=${encodeURIComponent(path)}`);
260
- list.innerHTML = '';
261
- if(!items.length){list.innerHTML='<div class="fm-empty">Empty folder</div>';return;}
262
- items.forEach(item => {
263
- const el = document.createElement('div');
264
- el.className = 'fm-item';
265
- const icon = getIcon(item);
266
- el.innerHTML = `<i class="fm-icon ${icon.cls} ${icon.ic}"></i><span class="fm-name">${item.name}</span><span class="fm-size">${item.is_dir?'—':fmtSize(item.size)}</span>`;
267
- el.addEventListener('click',()=>selectItem(item,el));
268
- el.addEventListener('dblclick',()=>openItem(item));
269
- el.addEventListener('contextmenu',e=>{e.preventDefault();selectItem(item,el);showCtx(e,item);});
270
- list.appendChild(el);
271
- });
272
- }
273
- function selectItem(item,el){
274
- document.querySelectorAll('.fm-item').forEach(e=>e.classList.remove('selected'));
275
- el.classList.add('selected'); selectedItem=item;
276
- }
277
- function getIcon(item){
278
- if(item.is_dir) return {cls:'fi-dir',ic:'fa-solid fa-folder'};
279
- const ext = item.name.split('.').pop().toLowerCase();
280
- if(['yml','yaml','json','toml','cfg','conf'].includes(ext)) return {cls:'fi-cfg',ic:'fa-solid fa-file-code'};
281
- if(ext==='jar') return {cls:'fi-jar',ic:'fa-solid fa-cube'};
282
- if(ext==='log') return {cls:'fi-log',ic:'fa-solid fa-file-lines'};
283
- if(['txt','md'].includes(ext)) return {cls:'fi-other',ic:'fa-solid fa-file-alt'};
284
- return {cls:'fi-other',ic:'fa-solid fa-file'};
285
- }
286
- function fmtSize(b){if(b<1024)return b+'B';if(b<1048576)return (b/1024).toFixed(1)+'KB';return (b/1048576).toFixed(1)+'MB';}
287
- function renderBreadcrumb(path){
288
- const bc = document.getElementById('breadcrumb');
289
- const parts = path ? path.split('/').filter(Boolean) : [];
290
- let html = `<span onclick="loadDir('')"><i class="fa-solid fa-server" style="color:var(--accent)"></i></span>`;
291
- let acc = '';
292
- parts.forEach(p=>{acc+=(acc?'/':'')+p;const cp=acc;html+=`<span class="sep">/</span><span onclick="loadDir('${cp}')">${p}</span>`;});
293
- bc.innerHTML = html;
294
- }
295
- function openItem(item){
296
- const fp = (currentPath ? currentPath+'/' : '') + item.name;
297
- if(item.is_dir){loadDir(fp);return;}
298
- const ext = item.name.split('.').pop().toLowerCase();
299
- const editable = ['yml','yaml','json','toml','cfg','conf','txt','md','properties','log','sh','py','js'].includes(ext);
300
- if(editable) openEditor(fp,item.name);
301
- else downloadFile(fp,item.name);
302
- }
303
- function fullPath(name){return (currentPath ? currentPath+'/' : '') + name;}
304
-
305
- async function openEditor(fp,name){
306
- const res = await fetch(`/api/fs/read?path=${encodeURIComponent(fp)}`);
307
- if(!res.ok){toast('Cannot read binary file');return;}
308
- const text = await res.text();
309
- openModal('Edit — '+name,'wide');
310
- M.body.innerHTML = `<textarea id="editor-ta" spellcheck="false">${escHtml(text)}</textarea>`;
311
- M.actions.innerHTML = `<button class="btn-ghost" onclick="closeModal()">Cancel</button><button class="btn-primary" onclick="saveFile('${fp}')">Save</button>`;
312
- }
313
- async function saveFile(fp){
314
- const content = document.getElementById('editor-ta').value;
315
- const fd = new FormData(); fd.append('path',fp); fd.append('content',content);
316
- await apiPost('/api/fs/write',fd);
317
- toast('Saved'); closeModal();
318
- }
319
- function downloadFile(fp,name){window.location='/api/fs/download?path='+encodeURIComponent(fp);}
320
-
321
- function showCtx(e,item){
322
- document.querySelectorAll('.ctx-menu').forEach(c=>c.remove());
323
- const fp = fullPath(item.name);
324
- const m = document.createElement('div'); m.className='ctx-menu';
325
- const items = [
326
- {icon:'fa-solid fa-pen',label:'Rename',fn:()=>showRename(fp,item.name)},
327
- ...(item.is_dir?[]:[{icon:'fa-solid fa-edit',label:'Edit',fn:()=>openEditor(fp,item.name)},{icon:'fa-solid fa-download',label:'Download',fn:()=>downloadFile(fp,item.name)}]),
328
- {sep:true},
329
- {icon:'fa-solid fa-trash',label:'Delete',fn:()=>showDelete(fp,item.name),danger:true},
330
- ];
331
- items.forEach(it=>{
332
- if(it.sep){const s=document.createElement('div');s.className='ctx-sep';m.appendChild(s);return;}
333
- const d=document.createElement('div');d.className='ctx-item'+(it.danger?' danger':'');
334
- d.innerHTML=`<i class="${it.icon}"></i>${it.label}`;
335
- d.onclick=()=>{m.remove();it.fn();};
336
- m.appendChild(d);
337
- });
338
- m.style.top = Math.min(e.clientY,window.innerHeight-m.offsetHeight-10)+'px';
339
- m.style.left = Math.min(e.clientX,window.innerWidth-180)+'px';
340
- document.body.appendChild(m);
341
- setTimeout(()=>document.addEventListener('click',()=>m.remove(),{once:true}),10);
342
- }
343
-
344
- function showRename(fp,name){
345
- openModal('Rename');
346
- M.body.innerHTML=`<input id="rename-in" value="${name}" autocomplete="off">`;
347
- M.actions.innerHTML=`<button class="btn-ghost" onclick="closeModal()">Cancel</button><button class="btn-primary" onclick="doRename('${fp}')">Rename</button>`;
348
- setTimeout(()=>{const i=document.getElementById('rename-in');i.focus();i.select();},50);
349
- }
350
- async function doRename(fp){
351
- const nv = document.getElementById('rename-in').value.trim();
352
- if(!nv) return;
353
- const fd=new FormData();fd.append('path',fp);fd.append('new_name',nv);
354
- await apiPost('/api/fs/rename',fd);
355
- closeModal(); loadDir(currentPath);
356
- }
357
- function showDelete(fp,name){
358
- openModal('Delete');
359
- M.body.innerHTML=`<p style="color:var(--t2);font-size:13px;margin-bottom:16px">Delete <strong style="color:var(--t1)">${name}</strong>? This cannot be undone.</p>`;
360
- M.actions.innerHTML=`<button class="btn-ghost" onclick="closeModal()">Cancel</button><button class="btn-danger" onclick="doDelete('${fp}')">Delete</button>`;
361
- }
362
- async function doDelete(fp){
363
- const fd=new FormData();fd.append('path',fp);
364
- await apiPost('/api/fs/delete',fd);
365
- closeModal(); loadDir(currentPath);
366
- }
367
- function showMkdir(){
368
- openModal('New Folder');
369
- M.body.innerHTML=`<input id="mkdir-in" placeholder="Folder name" autocomplete="off">`;
370
- M.actions.innerHTML=`<button class="btn-ghost" onclick="closeModal()">Cancel</button><button class="btn-primary" onclick="doMkdir()">Create</button>`;
371
- setTimeout(()=>document.getElementById('mkdir-in').focus(),50);
372
- }
373
- async function doMkdir(){
374
- const n=document.getElementById('mkdir-in').value.trim();if(!n)return;
375
- const fd=new FormData();fd.append('path',(currentPath?currentPath+'/':'')+n);
376
- await apiPost('/api/fs/mkdir',fd);
377
- closeModal(); loadDir(currentPath);
378
- }
379
- function showUpload(){
380
- openModal('Upload Files');
381
- M.body.innerHTML=`<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-in').click()">
382
- <i class="fa-solid fa-cloud-arrow-up"></i><p>Click or drag & drop files</p></div>
383
- <input type="file" id="file-in" multiple style="display:none" onchange="handleUpload(this.files)">
384
- <div id="upload-prog"></div>`;
385
- M.actions.innerHTML=`<button class="btn-ghost" onclick="closeModal()">Close</button>`;
386
- const dz=document.getElementById('drop-zone');
387
- dz.ondragover=e=>{e.preventDefault();dz.classList.add('drag')};
388
- dz.ondragleave=()=>dz.classList.remove('drag');
389
- dz.ondrop=e=>{e.preventDefault();dz.classList.remove('drag');handleUpload(e.dataTransfer.files);};
390
- }
391
- async function handleUpload(files){
392
- const prog=document.getElementById('upload-prog');
393
- for(const file of files){
394
- prog.innerHTML=`<p style="font-size:12px;color:var(--t2);margin-bottom:4px">Uploading ${file.name}...</p>`;
395
- const fd=new FormData();fd.append('path',currentPath);fd.append('file',file);
396
- await apiPost('/api/fs/upload',fd);
397
- }
398
- prog.innerHTML=`<p style="font-size:12px;color:var(--accent)">Done!</p>`;
399
- loadDir(currentPath);
400
- }
401
-
402
- // CONFIG
403
- async function loadConfig(){
404
- const wrap = document.getElementById('cfg-wrap');
405
- wrap.innerHTML='<div class="fm-empty"><i class="fa-solid fa-spinner fa-spin"></i>&nbsp;Loading...</div>';
406
- const res = await fetch('/api/fs/read?path='+encodeURIComponent('server.properties'));
407
- if(!res.ok){wrap.innerHTML='<div class="fm-empty" style="flex-direction:column;gap:8px"><i class="fa-solid fa-circle-exclamation" style="color:var(--t3)"></i><p style="font-size:13px;color:var(--t3)">server.properties not found</p></div>';return;}
408
- const text = await res.text();
409
- const groups = {General:[],World:[],Network:[],Game:[],Performance:[]};
410
- const gmap={motd:'General',server_ip:'Network','server-port':'Network','max-players':'General','online-mode':'Network','enable-rcon':'Network','rcon.port':'Network','rcon.password':'Network','level-name':'World','level-seed':'World','gamemode':'Game','difficulty':'Game','hardcore':'Game','pvp':'Game','spawn-monsters':'Game','spawn-animals':'Game','spawn-npcs':'Game','view-distance':'Performance','simulation-distance':'Performance','max-tick-time':'Performance','network-compression-threshold':'Performance'};
411
- const lines=text.split('\n').filter(l=>l&&!l.startsWith('#'));
412
- const entries=[];
413
- lines.forEach(l=>{const eq=l.indexOf('=');if(eq<0)return;const k=l.slice(0,eq).trim(),v=l.slice(eq+1).trim();entries.push({k,v});});
414
- const grouped={General:[],World:[],Network:[],Game:[],Performance:[],Other:[]};
415
- entries.forEach(e=>{const g=gmap[e.k]||'Other';grouped[g].push(e);});
416
- wrap.innerHTML='';
417
- Object.entries(grouped).forEach(([g,rows])=>{
418
- if(!rows.length) return;
419
- const sec=document.createElement('div');sec.className='cfg-section';
420
- sec.innerHTML=`<div class="cfg-section-head">${g}</div>`;
421
- rows.forEach(({k,v})=>{
422
- const r=document.createElement('div');r.className='cfg-row';
423
- r.innerHTML=`<span class="cfg-key">${k}</span><input class="cfg-val" data-key="${k}" value="${escHtml(v)}">`;
424
- sec.appendChild(r);
425
- });
426
- wrap.appendChild(sec);
427
- });
428
- const btn=document.createElement('button');btn.className='cfg-save';btn.textContent='Save Changes';
429
- btn.onclick=saveConfig;wrap.appendChild(btn);
430
- }
431
- async function saveConfig(){
432
- const res=await fetch('/api/fs/read?path=server.properties');
433
- let text=await res.text();
434
- document.querySelectorAll('.cfg-val').forEach(inp=>{
435
- const k=inp.dataset.key,v=inp.value;
436
- text=text.replace(new RegExp(`^(${k}\\s*=).*$`,'m'),`$1${v}`);
437
- });
438
- const fd=new FormData();fd.append('path','server.properties');fd.append('content',text);
439
- await apiPost('/api/fs/write',fd);
440
- toast('server.properties saved');
441
- }
442
-
443
- // MODAL
444
- const M={el:null,body:null,actions:null};
445
- function openModal(title,cls=''){
446
- const ov=document.getElementById('overlay');
447
- const mo=document.getElementById('modal');
448
- mo.className='modal'+(cls?' '+cls:'');
449
- document.getElementById('modal-title').textContent=title;
450
- M.body=document.getElementById('modal-body');M.body.innerHTML='';
451
- M.actions=document.getElementById('modal-actions');M.actions.innerHTML='';
452
- ov.style.display='flex';
453
- }
454
- function closeModal(e){
455
- if(e&&e.target!==document.getElementById('overlay'))return;
456
- document.getElementById('overlay').style.display='none';
457
- }
458
- document.addEventListener('keydown',e=>{if(e.key==='Escape')document.getElementById('overlay').style.display='none';});
459
-
460
- // TOAST
461
- function toast(msg){
462
- const t=document.createElement('div');
463
- t.style.cssText='position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:#1a1a1a;border:1px solid var(--b1);color:var(--t1);padding:8px 18px;border-radius:8px;font-size:13px;z-index:9999;animation:fadeIn .2s ease;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,.5)';
464
- t.textContent=msg;document.body.appendChild(t);
465
- setTimeout(()=>t.remove(),2200);
466
- }
467
- function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
468
-
469
- // Init
470
- loadDir('');
471
- </script>
472
  </body>
473
  </html>
474
  """
@@ -480,7 +447,7 @@ def get_safe_path(subpath: str):
480
  subpath = (subpath or "").strip("/")
481
  target = os.path.abspath(os.path.join(BASE_DIR, subpath))
482
  if not target.startswith(BASE_DIR):
483
- raise HTTPException(status_code=403, detail="Access denied outside server directory")
484
  return target
485
 
486
  async def broadcast(message: str):
@@ -598,23 +565,6 @@ async def fs_upload(path: str = Form(""), file: UploadFile = File(...)):
598
  shutil.copyfileobj(file.file, buffer)
599
  return {"status": "ok"}
600
 
601
- @app.post("/api/fs/rename")
602
- def fs_rename(path: str = Form(...), new_name: str = Form(...)):
603
- target = get_safe_path(path)
604
- if not os.path.exists(target):
605
- raise HTTPException(404, "File not found")
606
- if "/" in new_name or "\\" in new_name:
607
- raise HTTPException(400, "Invalid new name")
608
- new_target = get_safe_path(os.path.join(os.path.dirname(path), new_name))
609
- os.rename(target, new_target)
610
- return {"status": "ok"}
611
-
612
- @app.post("/api/fs/mkdir")
613
- def fs_mkdir(path: str = Form(...)):
614
- target = get_safe_path(path)
615
- os.makedirs(target, exist_ok=True)
616
- return {"status": "ok"}
617
-
618
  @app.post("/api/fs/delete")
619
  def fs_delete(path: str = Form(...)):
620
  target = get_safe_path(path)
 
13
  mc_process = None
14
  output_history = collections.deque(maxlen=300)
15
  connected_clients = set()
16
+ BASE_DIR = os.path.abspath("/app")
 
17
 
18
  # -----------------
19
  # HTML FRONTEND (Ultra-Modern UI)
20
  # -----------------
21
+ HTML_CONTENT = """
22
+ <!DOCTYPE html>
23
+ <html lang="en" class="dark">
24
  <head>
25
+ <meta charset="UTF-8">
26
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
27
+ <title>Server Console</title>
28
+ <script src="https://cdn.tailwindcss.com"></script>
29
+ <script src="https://unpkg.com/lucide@latest"></script>
30
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css" />
31
+ <script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
32
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
33
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
34
+ <style>
35
+ :root { --bg: #09090b; --surface: #18181b; --surface-hover: #27272a; --border: #27272a; --text: #fafafa; --text-muted: #a1a1aa; --accent: #3b82f6; }
36
+ body { background-color: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; overflow: hidden; -webkit-font-smoothing: antialiased; }
37
+ .font-mono { font-family: 'JetBrains Mono', monospace; }
38
+
39
+ /* Glass Navbar */
40
+ .glass-nav { background: rgba(9, 9, 11, 0.8); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); z-index: 40; }
41
+
42
+ /* Animations */
43
+ .fade-in { animation: fadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
44
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
45
+
46
+ /* Terminal Styling with Top Fade */
47
+ .term-container { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; position: relative; }
48
+ .term-wrapper { padding: 12px; height: calc(100vh - 180px); width: 100%; mask-image: linear-gradient(to bottom, transparent 0%, black 5%, black 100%); -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 5%, black 100%); }
49
+ .xterm-viewport::-webkit-scrollbar { width: 8px; }
50
+ .xterm-viewport::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 4px; }
51
+
52
+ /* File Manager Layout */
53
+ .file-row { transition: all 0.15s ease; border-bottom: 1px solid var(--border); }
54
+ .file-row:hover { background: var(--surface-hover); }
55
+ .file-row:last-child { border-bottom: none; }
56
+
57
+ /* Custom Scrollbars */
58
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
59
+ ::-webkit-scrollbar-track { background: transparent; }
60
+ ::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 3px; }
61
+ ::-webkit-scrollbar-thumb:hover { background: #52525b; }
62
+
63
+ /* Loader */
64
+ .loader { animation: spin 1s linear infinite; }
65
+ @keyframes spin { 100% { transform: rotate(360deg); } }
66
+
67
+ /* Utility */
68
+ .hidden-tab { display: none !important; }
69
+ input[type="text"]:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
70
+ </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  </head>
72
+ <body class="flex flex-col h-screen w-full">
73
+
74
+ <!-- Top Navigation -->
75
+ <nav class="glass-nav w-full px-4 sm:px-6 py-3 flex justify-between items-center fixed top-0 left-0 right-0 h-[60px]">
76
+ <div class="flex items-center gap-3">
77
+ <div class="bg-blue-500/10 p-2 rounded-lg border border-blue-500/20">
78
+ <i data-lucide="server" class="w-5 h-5 text-blue-400"></i>
79
+ </div>
80
+ <span class="font-semibold tracking-tight text-sm sm:text-base text-gray-100">Minecraft Engine</span>
81
+ <span class="px-2 py-0.5 rounded-full bg-green-500/10 text-green-400 border border-green-500/20 text-[10px] font-bold tracking-wide uppercase hidden sm:block">Online</span>
82
+ </div>
83
+
84
+ <div class="flex gap-1 sm:gap-2 bg-zinc-900 p-1 rounded-lg border border-zinc-800">
85
+ <button onclick="switchTab('console')" id="btn-console" class="flex items-center gap-2 px-3 py-1.5 sm:px-4 sm:py-2 bg-zinc-800 text-gray-100 rounded-md text-xs sm:text-sm font-medium transition-all shadow-sm">
86
+ <i data-lucide="terminal" class="w-4 h-4"></i><span class="hidden sm:inline">Console</span>
87
+ </button>
88
+ <button onclick="switchTab('files')" id="btn-files" class="flex items-center gap-2 px-3 py-1.5 sm:px-4 sm:py-2 text-zinc-400 hover:text-gray-200 rounded-md text-xs sm:text-sm font-medium transition-all">
89
+ <i data-lucide="folder-code" class="w-4 h-4"></i><span class="hidden sm:inline">Files</span>
90
+ </button>
91
+ </div>
92
+ </nav>
93
+
94
+ <!-- Main Content Area -->
95
+ <main class="mt-[60px] flex-grow p-3 sm:p-4 overflow-hidden relative">
96
+
97
+ <!-- Console Tab -->
98
+ <div id="tab-console" class="h-full w-full max-w-7xl mx-auto flex flex-col fade-in">
99
+ <div class="term-container shadow-2xl flex-grow flex flex-col">
100
+ <div class="bg-zinc-900 border-b border-zinc-800 px-4 py-2 flex items-center gap-2 text-xs text-zinc-400 font-mono">
101
+ <div class="flex gap-1.5"><div class="w-2.5 h-2.5 rounded-full bg-red-500/80"></div><div class="w-2.5 h-2.5 rounded-full bg-yellow-500/80"></div><div class="w-2.5 h-2.5 rounded-full bg-green-500/80"></div></div>
102
+ <span class="ml-2">server-stdout</span>
103
+ </div>
104
+ <div id="terminal" class="term-wrapper"></div>
105
+ </div>
106
+
107
+ <div class="mt-3 sm:mt-4 relative">
108
+ <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-zinc-500">
109
+ <i data-lucide="chevron-right" class="w-5 h-5"></i>
110
+ </div>
111
+ <input type="text" id="cmd-input" class="w-full bg-zinc-900 border border-zinc-800 text-gray-200 rounded-lg pl-10 pr-12 py-3 text-sm font-mono transition-all shadow-inner placeholder-zinc-600" placeholder="Execute command...">
112
+ <button onclick="sendCommand()" class="absolute inset-y-1 right-1 px-3 bg-blue-600 hover:bg-blue-500 text-white rounded-md transition-colors flex items-center justify-center">
113
+ <i data-lucide="send" class="w-4 h-4"></i>
114
+ </button>
115
+ </div>
116
+ </div>
117
+
118
+ <!-- File Manager Tab -->
119
+ <div id="tab-files" class="hidden-tab h-full w-full max-w-7xl mx-auto flex flex-col bg-[#18181b] rounded-xl border border-zinc-800 shadow-2xl overflow-hidden">
120
+ <!-- File Header / Breadcrumbs -->
121
+ <div class="bg-zinc-900/50 px-4 py-3 border-b border-zinc-800 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
122
+ <div class="flex items-center text-sm font-mono text-zinc-400 overflow-x-auto whitespace-nowrap hide-scrollbar w-full sm:w-auto" id="breadcrumbs">
123
+ <!-- Injected via JS -->
124
+ </div>
125
+ <div class="flex items-center gap-2 shrink-0">
126
+ <input type="file" id="file-upload" class="hidden" onchange="uploadFile(event)">
127
+ <button onclick="document.getElementById('file-upload').click()" class="flex items-center gap-1.5 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 px-3 py-1.5 rounded-md text-xs font-medium text-gray-200 transition-colors">
128
+ <i data-lucide="upload-cloud" class="w-4 h-4 text-blue-400"></i> Upload
129
+ </button>
130
+ <button onclick="loadFiles(currentPath)" class="flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 w-8 h-8 rounded-md transition-colors">
131
+ <i data-lucide="refresh-cw" class="w-4 h-4 text-zinc-400"></i>
132
+ </button>
133
+ </div>
134
+ </div>
135
+
136
+ <!-- File List Columns -->
137
+ <div class="hidden sm:grid grid-cols-12 gap-4 px-5 py-2 border-b border-zinc-800 bg-zinc-900/80 text-xs font-semibold text-zinc-500 uppercase tracking-wider">
138
+ <div class="col-span-7">Name</div>
139
+ <div class="col-span-3 text-right">Size</div>
140
+ <div class="col-span-2 text-right">Actions</div>
141
+ </div>
142
+
143
+ <!-- File List -->
144
+ <div class="flex-grow overflow-y-auto" id="file-list">
145
+ <!-- Injected via JS -->
146
+ </div>
147
+ </div>
148
+ </main>
149
+
150
+ <!-- Code Editor Modal -->
151
+ <div id="editor-modal" class="fixed inset-0 bg-black/60 backdrop-blur-sm hidden items-center justify-center p-2 sm:p-6 z-50 opacity-0 transition-opacity duration-300">
152
+ <div class="bg-[#18181b] rounded-xl border border-zinc-800 w-full max-w-5xl h-[90vh] sm:h-[85vh] flex flex-col shadow-2xl transform scale-95 transition-transform duration-300" id="editor-card">
153
+ <div class="bg-zinc-900 px-4 py-3 flex justify-between items-center border-b border-zinc-800 rounded-t-xl">
154
+ <div class="flex items-center gap-2 text-sm font-mono text-gray-300">
155
+ <i data-lucide="file-code" class="w-4 h-4 text-blue-400"></i>
156
+ <span id="editor-title">filename.txt</span>
157
+ </div>
158
+ <div class="flex items-center gap-2">
159
+ <button onclick="closeEditor()" class="px-3 py-1.5 hover:bg-zinc-800 rounded text-xs font-medium text-zinc-400 transition-colors">Cancel</button>
160
+ <button onclick="saveFile()" class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs font-semibold transition-colors flex items-center gap-1.5 shadow-lg shadow-blue-500/20">
161
+ <i data-lucide="save" class="w-3.5 h-3.5"></i> Save
162
+ </button>
163
+ </div>
164
+ </div>
165
+ <textarea id="editor-content" class="flex-grow bg-[#0e0e11] text-zinc-300 p-4 font-mono text-xs sm:text-sm resize-none focus:outline-none w-full leading-relaxed" spellcheck="false"></textarea>
166
+ </div>
167
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
+ <!-- Toast Notifications -->
170
+ <div id="toast-container" class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2"></div>
171
+
172
+ <script>
173
+ // Initialize Lucide Icons
174
+ lucide.createIcons();
175
+
176
+ // --- Toast System ---
177
+ function showToast(message, type = 'info') {
178
+ const container = document.getElementById('toast-container');
179
+ const toast = document.createElement('div');
180
+
181
+ let icon = '<i data-lucide="info" class="w-4 h-4 text-blue-400"></i>';
182
+ let border = 'border-blue-500/20';
183
+ if(type === 'success') { icon = '<i data-lucide="check-circle" class="w-4 h-4 text-green-400"></i>'; border = 'border-green-500/20'; }
184
+ if(type === 'error') { icon = '<i data-lucide="alert-circle" class="w-4 h-4 text-red-400"></i>'; border = 'border-red-500/20'; }
185
+
186
+ toast.className = `flex items-center gap-3 bg-zinc-900 border ${border} text-sm text-gray-200 px-4 py-3 rounded-lg shadow-xl translate-y-8 opacity-0 transition-all duration-300`;
187
+ toast.innerHTML = `${icon} <span>${message}</span>`;
188
+
189
+ container.appendChild(toast);
190
+ lucide.createIcons();
191
+
192
+ // Animate In
193
+ requestAnimationFrame(() => {
194
+ toast.classList.remove('translate-y-8', 'opacity-0');
195
+ });
196
+
197
+ // Animate Out
198
+ setTimeout(() => {
199
+ toast.classList.add('translate-y-8', 'opacity-0');
200
+ setTimeout(() => toast.remove(), 300);
201
+ }, 3000);
202
+ }
203
+
204
+ // --- UI Navigation ---
205
+ function switchTab(tab) {
206
+ document.getElementById('tab-console').classList.add('hidden-tab');
207
+ document.getElementById('tab-files').classList.add('hidden-tab');
208
+
209
+ document.getElementById('btn-console').className = "flex items-center gap-2 px-3 py-1.5 sm:px-4 sm:py-2 text-zinc-400 hover:text-gray-200 rounded-md text-xs sm:text-sm font-medium transition-all";
210
+ document.getElementById('btn-files').className = "flex items-center gap-2 px-3 py-1.5 sm:px-4 sm:py-2 text-zinc-400 hover:text-gray-200 rounded-md text-xs sm:text-sm font-medium transition-all";
211
+
212
+ document.getElementById('tab-' + tab).classList.remove('hidden-tab');
213
+ document.getElementById('tab-' + tab).classList.add('fade-in');
214
+
215
+ const activeBtn = document.getElementById('btn-' + tab);
216
+ activeBtn.className = "flex items-center gap-2 px-3 py-1.5 sm:px-4 sm:py-2 bg-zinc-800 text-gray-100 rounded-md text-xs sm:text-sm font-medium transition-all shadow-sm";
217
+
218
+ if(tab === 'console' && fitAddon) { setTimeout(() => fitAddon.fit(), 50); }
219
+ if(tab === 'files' && !currentPathLoaded) { loadFiles(''); currentPathLoaded = true; }
220
+ }
221
+
222
+ // --- Terminal Logic ---
223
+ const term = new Terminal({
224
+ theme: { background: 'transparent', foreground: '#e4e4e7', cursor: '#3b82f6', selectionBackground: 'rgba(59, 130, 246, 0.3)' },
225
+ convertEol: true, cursorBlink: true, fontFamily: "'JetBrains Mono', monospace", fontSize: 13, fontWeight: 400
226
+ });
227
+ const fitAddon = new FitAddon.FitAddon();
228
+ term.loadAddon(fitAddon);
229
+ term.open(document.getElementById('terminal'));
230
+
231
+ // Wait slightly for DOM to render to fit exactly
232
+ setTimeout(() => fitAddon.fit(), 100);
233
+ window.addEventListener('resize', () => { if(!document.getElementById('tab-console').classList.contains('hidden-tab')) fitAddon.fit(); });
234
+
235
+ const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws';
236
+ let ws;
237
+
238
+ function connectWS() {
239
+ ws = new WebSocket(wsUrl);
240
+ ws.onopen = () => term.write('\\x1b[32m\\x1b[1m[Panel]\\x1b[0m Connected to server stream.\\r\\n');
241
+ ws.onmessage = e => term.write(e.data + '\\n');
242
+ ws.onclose = () => { term.write('\\r\\n\\x1b[31m\\x1b[1m[Panel]\\x1b[0m Connection lost. Reconnecting in 3s...\\r\\n'); setTimeout(connectWS, 3000); };
243
+ }
244
+ connectWS();
245
+
246
+ const cmdInput = document.getElementById('cmd-input');
247
+ cmdInput.addEventListener('keypress', e => { if (e.key === 'Enter') sendCommand(); });
248
+
249
+ function sendCommand() {
250
+ const val = cmdInput.value.trim();
251
+ if(val && ws && ws.readyState === WebSocket.OPEN) {
252
+ term.write(`\\x1b[90m> ${val}\\x1b[0m\\r\\n`);
253
+ ws.send(val);
254
+ cmdInput.value = '';
255
+ }
256
+ }
257
+
258
+ // --- File Manager Logic ---
259
+ let currentPath = '';
260
+ let currentPathLoaded = false;
261
+ let editingFilePath = '';
262
+
263
+ function renderBreadcrumbs(path) {
264
+ const parts = path.split('/').filter(p => p);
265
+ let html = `<button onclick="loadFiles('')" class="hover:text-gray-200 transition-colors"><i data-lucide="home" class="w-4 h-4"></i></button>`;
266
+ let buildPath = '';
267
+
268
+ if (parts.length > 0) {
269
+ parts.forEach((part, index) => {
270
+ buildPath += (buildPath ? '/' : '') + part;
271
+ html += ` <i data-lucide="chevron-right" class="w-3.5 h-3.5 mx-1 opacity-50"></i> `;
272
+ if(index === parts.length - 1) {
273
+ html += `<span class="text-blue-400 font-medium">${part}</span>`;
274
+ } else {
275
+ html += `<button onclick="loadFiles('${buildPath}')" class="hover:text-gray-200 transition-colors">${part}</button>`;
276
+ }
277
+ });
278
+ }
279
+ document.getElementById('breadcrumbs').innerHTML = html;
280
+ lucide.createIcons();
281
+ }
282
+
283
+ async function loadFiles(path) {
284
+ currentPath = path;
285
+ renderBreadcrumbs(path);
286
+ const list = document.getElementById('file-list');
287
+ list.innerHTML = `<div class="flex justify-center py-8"><i data-lucide="loader-2" class="w-6 h-6 text-zinc-500 loader"></i></div>`;
288
+ lucide.createIcons();
289
+
290
+ try {
291
+ const res = await fetch(`/api/fs/list?path=${encodeURIComponent(path)}`);
292
+ if(!res.ok) throw new Error('Failed to load');
293
+ const files = await res.json();
294
+ list.innerHTML = '';
295
+
296
+ if (path !== '') {
297
+ const parent = path.split('/').slice(0, -1).join('/');
298
+ list.innerHTML += `
299
+ <div class="file-row flex items-center px-5 py-3 cursor-pointer" onclick="loadFiles('${parent}')">
300
+ <div class="flex items-center gap-3 w-full">
301
+ <i data-lucide="corner-left-up" class="w-4 h-4 text-zinc-500"></i>
302
+ <span class="text-sm font-mono text-zinc-400">..</span>
303
+ </div>
304
+ </div>`;
305
+ }
306
+
307
+ if(files.length === 0 && path === '') {
308
+ list.innerHTML += `<div class="text-center py-8 text-zinc-500 text-sm">Directory is empty</div>`;
309
+ }
310
+
311
+ files.forEach(f => {
312
+ const icon = f.is_dir ? '<i data-lucide="folder" class="w-4 h-4 text-blue-400 fill-blue-400/10"></i>' : '<i data-lucide="file" class="w-4 h-4 text-zinc-400"></i>';
313
+ const sizeStr = f.is_dir ? '--' : (f.size > 1024*1024 ? (f.size/(1024*1024)).toFixed(1) + ' MB' : (f.size / 1024).toFixed(1) + ' KB');
314
+ const fullPath = path ? `${path}/${f.name}` : f.name;
315
+ const actionClick = f.is_dir ? `onclick="loadFiles('${fullPath}')"` : '';
316
+ const pointer = f.is_dir ? 'cursor-pointer' : '';
317
+
318
+ list.innerHTML += `
319
+ <div class="file-row flex flex-col sm:grid sm:grid-cols-12 items-start sm:items-center px-5 py-3 gap-2 sm:gap-4 group">
320
+ <div class="col-span-7 flex items-center gap-3 w-full ${pointer}" ${actionClick}>
321
+ ${icon}
322
+ <span class="text-sm font-mono text-gray-200 truncate group-hover:text-blue-400 transition-colors">${f.name}</span>
323
+ </div>
324
+ <div class="col-span-3 text-right text-xs text-zinc-500 font-mono hidden sm:block">${sizeStr}</div>
325
+ <div class="col-span-2 flex justify-end gap-1 sm:gap-2 w-full sm:w-auto mt-2 sm:mt-0 sm:opacity-0 group-hover:opacity-100 transition-opacity">
326
+ ${!f.is_dir ? `<button onclick="editFile('${fullPath}')" class="p-1.5 text-zinc-400 hover:text-blue-400 hover:bg-blue-500/10 rounded transition-colors" title="Edit"><i data-lucide="edit-3" class="w-4 h-4"></i></button>` : ''}
327
+ ${!f.is_dir ? `<a href="/api/fs/download?path=${encodeURIComponent(fullPath)}" class="p-1.5 text-zinc-400 hover:text-green-400 hover:bg-green-500/10 rounded transition-colors inline-block" title="Download"><i data-lucide="download" class="w-4 h-4"></i></a>` : ''}
328
+ <button onclick="deleteFile('${fullPath}')" class="p-1.5 text-zinc-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors" title="Delete"><i data-lucide="trash-2" class="w-4 h-4"></i></button>
329
+ </div>
330
+ </div>`;
331
+ });
332
+ lucide.createIcons();
333
+ } catch (err) {
334
+ showToast("Failed to load directory", "error");
335
+ list.innerHTML = `<div class="text-center py-8 text-red-400 text-sm">Error loading files</div>`;
336
+ }
337
+ }
338
+
339
+ async function editFile(path) {
340
+ try {
341
+ const res = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
342
+ if(res.ok) {
343
+ const text = await res.text();
344
+ editingFilePath = path;
345
+ document.getElementById('editor-content').value = text;
346
+ document.getElementById('editor-title').innerText = path.split('/').pop();
347
+
348
+ const modal = document.getElementById('editor-modal');
349
+ const card = document.getElementById('editor-card');
350
+ modal.classList.remove('hidden');
351
+ modal.classList.add('flex');
352
+
353
+ // Animate In
354
+ requestAnimationFrame(() => {
355
+ modal.classList.remove('opacity-0');
356
+ card.classList.remove('scale-95');
357
+ });
358
+ } else {
359
+ showToast('Cannot open file (might be binary)', 'error');
360
+ }
361
+ } catch {
362
+ showToast('Failed to open file', 'error');
363
+ }
364
+ }
365
+
366
+ function closeEditor() {
367
+ const modal = document.getElementById('editor-modal');
368
+ const card = document.getElementById('editor-card');
369
+ modal.classList.add('opacity-0');
370
+ card.classList.add('scale-95');
371
+ setTimeout(() => {
372
+ modal.classList.add('hidden');
373
+ modal.classList.remove('flex');
374
+ }, 300);
375
+ }
376
+
377
+ async function saveFile() {
378
+ const btn = document.querySelector('#editor-modal button.bg-blue-600');
379
+ const originalHTML = btn.innerHTML;
380
+ btn.innerHTML = `<i data-lucide="loader-2" class="w-3.5 h-3.5 loader"></i> Saving...`;
381
+ lucide.createIcons();
382
+
383
+ const content = document.getElementById('editor-content').value;
384
+ const formData = new FormData();
385
+ formData.append('path', editingFilePath);
386
+ formData.append('content', content);
387
+
388
+ try {
389
+ const res = await fetch('/api/fs/write', { method: 'POST', body: formData });
390
+ if(res.ok) {
391
+ showToast('File saved successfully', 'success');
392
+ closeEditor();
393
+ } else throw new Error();
394
+ } catch {
395
+ showToast('Failed to save file', 'error');
396
+ } finally {
397
+ btn.innerHTML = originalHTML;
398
+ lucide.createIcons();
399
+ }
400
+ }
401
+
402
+ async function deleteFile(path) {
403
+ if(confirm('Are you sure you want to delete ' + path.split('/').pop() + '?')) {
404
+ const formData = new FormData(); formData.append('path', path);
405
+ try {
406
+ const res = await fetch('/api/fs/delete', { method: 'POST', body: formData });
407
+ if(res.ok) {
408
+ showToast('Deleted successfully', 'success');
409
+ loadFiles(currentPath);
410
+ } else throw new Error();
411
+ } catch {
412
+ showToast('Failed to delete', 'error');
413
+ }
414
+ }
415
+ }
416
+
417
+ async function uploadFile(e) {
418
+ const fileInput = e.target;
419
+ if(!fileInput.files.length) return;
420
+
421
+ showToast('Uploading ' + fileInput.files[0].name + '...', 'info');
422
+
423
+ const formData = new FormData();
424
+ formData.append('path', currentPath);
425
+ formData.append('file', fileInput.files[0]);
426
+
427
+ try {
428
+ const res = await fetch('/api/fs/upload', { method: 'POST', body: formData });
429
+ if(res.ok) {
430
+ showToast('Upload complete', 'success');
431
+ loadFiles(currentPath);
432
+ } else throw new Error();
433
+ } catch {
434
+ showToast('Upload failed', 'error');
435
+ }
436
+ fileInput.value = '';
437
+ }
438
+ </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  </body>
440
  </html>
441
  """
 
447
  subpath = (subpath or "").strip("/")
448
  target = os.path.abspath(os.path.join(BASE_DIR, subpath))
449
  if not target.startswith(BASE_DIR):
450
+ raise HTTPException(status_code=403, detail="Access denied outside /app")
451
  return target
452
 
453
  async def broadcast(message: str):
 
565
  shutil.copyfileobj(file.file, buffer)
566
  return {"status": "ok"}
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  @app.post("/api/fs/delete")
569
  def fs_delete(path: str = Form(...)):
570
  target = get_safe_path(path)