function hugpanel() { return { // ── Auth State ── user: null, token: localStorage.getItem('hugpanel_token'), adminApiUrl: localStorage.getItem('hugpanel_admin_url') || '', authLoading: true, authMode: 'login', authError: '', authSubmitting: false, loginForm: { username: '', password: '' }, registerForm: { username: '', email: '', password: '' }, // ── State ── sidebarOpen: false, zones: [], currentZone: localStorage.getItem('hugpanel_zone') || null, activeTab: localStorage.getItem('hugpanel_tab') || 'files', maxZones: 0, motd: '', registrationDisabled: false, isDesktop: window.innerWidth >= 1024, tabs: [ { id: 'files', label: 'Files', icon: 'folder' }, { id: 'editor', label: 'Editor', icon: 'file-code' }, { id: 'terminal', label: 'Terminal', icon: 'terminal' }, { id: 'ports', label: 'Ports', icon: 'radio' }, { id: 'backup', label: 'Backup', icon: 'cloud' }, ], // Files files: [], currentPath: '', filesLoading: false, showNewFile: false, showNewFolder: false, newFileName: '', newFolderName: '', // Editor editorFile: null, editorContent: '', editorOriginal: '', editorDirty: false, // Terminal term: null, termWs: null, termFit: null, termZone: null, // Ports ports: [], newPort: null, newPortLabel: '', // Backup backupStatus: { configured: false, admin_url: null, running: false, last: null, error: null, progress: '' }, backupList: [], backupLoading: false, // Create Zone showCreateZone: false, createZoneName: '', createZoneDesc: '', // Rename showRename: false, renameOldPath: '', renameNewName: '', // Toast toast: { show: false, message: '', type: 'info' }, // ── Computed ── get currentPathParts() { return this.currentPath ? this.currentPath.split('/').filter(Boolean) : []; }, // ── Init ── async init() { // Load backup status to get adminApiUrl await this.loadBackupStatus(); if (this.backupStatus.admin_url) { this.adminApiUrl = this.backupStatus.admin_url; localStorage.setItem('hugpanel_admin_url', this.adminApiUrl); } // Load config (MOTD, registration state, zone limit) early await this._loadZoneLimit(); // Try to restore session from stored token if (this.token && this.adminApiUrl) { try { const resp = await fetch(`${this.adminApiUrl}/auth/me`, { headers: { 'Authorization': `Bearer ${this.token}` }, }); if (resp.ok) { const data = await resp.json(); this.user = data.user; } else { // Token invalid/expired — clear it this.token = null; localStorage.removeItem('hugpanel_token'); } } catch { // Worker unreachable — keep token, let user retry } } else if (!this.adminApiUrl) { // No admin URL available — can't verify token but don't clear it } else { // No token stored this.token = null; } this.authLoading = false; if (this.user) { await this._loadPanel(); } this.$nextTick(() => lucide.createIcons()); // Watch for icon updates this.$watch('zones', () => this.$nextTick(() => lucide.createIcons())); this.$watch('files', () => this.$nextTick(() => lucide.createIcons())); this.$watch('activeTab', () => this.$nextTick(() => lucide.createIcons())); this.$watch('currentZone', () => this.$nextTick(() => lucide.createIcons())); this.$watch('ports', () => this.$nextTick(() => lucide.createIcons())); this.$watch('backupList', () => this.$nextTick(() => lucide.createIcons())); this.$watch('backupStatus', () => this.$nextTick(() => lucide.createIcons())); this.$watch('showCreateZone', () => { this.$nextTick(() => { lucide.createIcons(); if (this.showCreateZone) this.$refs.zoneNameInput?.focus(); }); }); this.$watch('showNewFile', () => { this.$nextTick(() => { if (this.showNewFile) this.$refs.newFileInput?.focus(); }); }); this.$watch('showNewFolder', () => { this.$nextTick(() => { if (this.showNewFolder) this.$refs.newFolderInput?.focus(); }); }); this.$watch('showRename', () => { this.$nextTick(() => { lucide.createIcons(); if (this.showRename) this.$refs.renameInput?.focus(); }); }); // Track desktop breakpoint const mql = window.matchMedia('(min-width: 1024px)'); mql.addEventListener('change', (e) => { this.isDesktop = e.matches; }); // Persist session state this.$watch('currentZone', (val) => { if (val) localStorage.setItem('hugpanel_zone', val); else localStorage.removeItem('hugpanel_zone'); }); this.$watch('activeTab', (val) => localStorage.setItem('hugpanel_tab', val)); // Keyboard shortcut document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 's' && this.activeTab === 'editor') { e.preventDefault(); this.saveFile(); } }); }, // ── Toast ── notify(message, type = 'info') { this.toast = { show: true, message, type }; setTimeout(() => { this.toast.show = false; }, 3000); }, // ── API Helper ── async api(url, options = {}) { try { const headers = options.headers || {}; // Add JWT token to all API calls if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } const resp = await fetch(url, { ...options, headers: { ...headers, ...options.headers } }); if (!resp.ok) { const data = await resp.json().catch(() => ({ detail: resp.statusText })); throw new Error(data.detail || resp.statusText); } return await resp.json(); } catch (err) { this.notify(err.message, 'error'); throw err; } }, // ── Auth ── async _loadPanel() { await this.loadZones(); await this.loadBackupStatus(); // Restore saved zone if it still exists if (this.currentZone && this.zones.some(z => z.name === this.currentZone)) { await this.selectZone(this.currentZone); } else { this.currentZone = null; } // Fetch zone limit await this._loadZoneLimit(); }, async _loadZoneLimit() { if (!this.adminApiUrl) return; try { const resp = await fetch(`${this.adminApiUrl}/config`); if (resp.ok) { const data = await resp.json(); this.maxZones = data.max_zones || 0; this.motd = data.motd || ''; this.registrationDisabled = !!data.disable_registration; } } catch {} }, async login() { if (!this.adminApiUrl) { this.authError = 'ADMIN_API_URL chưa cấu hình trên server'; return; } this.authError = ''; this.authSubmitting = true; try { const resp = await fetch(`${this.adminApiUrl}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.loginForm), }); const data = await resp.json(); if (!resp.ok) { this.authError = data.error || 'Đăng nhập thất bại'; this.authSubmitting = false; return; } this.token = data.token; this.user = data.user; localStorage.setItem('hugpanel_token', data.token); await this._loadPanel(); this.$nextTick(() => lucide.createIcons()); } catch (err) { this.authError = 'Không thể kết nối Admin Server'; } this.authSubmitting = false; }, async register() { if (!this.adminApiUrl) { this.authError = 'ADMIN_API_URL chưa cấu hình trên server'; return; } this.authError = ''; this.authSubmitting = true; try { const resp = await fetch(`${this.adminApiUrl}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.registerForm), }); const data = await resp.json(); if (!resp.ok) { this.authError = data.error || 'Đăng ký thất bại'; this.authSubmitting = false; return; } this.token = data.token; this.user = data.user; localStorage.setItem('hugpanel_token', data.token); await this._loadPanel(); this.$nextTick(() => lucide.createIcons()); } catch (err) { this.authError = 'Không thể kết nối Admin Server'; } this.authSubmitting = false; }, logout() { this.token = null; this.user = null; localStorage.removeItem('hugpanel_token'); localStorage.removeItem('hugpanel_admin_url'); localStorage.removeItem('hugpanel_zone'); localStorage.removeItem('hugpanel_tab'); this.currentZone = null; this.disconnectTerminal(); }, // ── Zones ── async loadZones() { try { this.zones = await this.api('/api/zones'); } catch { this.zones = []; } }, async selectZone(name) { this.currentZone = name; this.currentPath = ''; this.editorFile = null; this.editorDirty = false; this.activeTab = 'files'; this.disconnectTerminal(); await this.loadFiles(); await this.loadPorts(); if (this.backupStatus.configured) { await this.loadBackupList(); } }, async createZone() { if (!this.createZoneName.trim()) return; if (this.maxZones > 0 && this.zones.length >= this.maxZones) { this.notify(`Đã đạt giới hạn ${this.maxZones} zones`, 'error'); return; } const form = new FormData(); form.append('name', this.createZoneName.trim()); form.append('description', this.createZoneDesc.trim()); try { await this.api('/api/zones', { method: 'POST', body: form }); this.showCreateZone = false; this.createZoneName = ''; this.createZoneDesc = ''; await this.loadZones(); this.notify('Zone đã được tạo'); } catch {} }, async confirmDeleteZone() { if (!this.currentZone) return; if (!confirm(`Xoá zone "${this.currentZone}"? Toàn bộ dữ liệu sẽ bị mất.`)) return; try { await this.api(`/api/zones/${this.currentZone}`, { method: 'DELETE' }); this.disconnectTerminal(); this.currentZone = null; await this.loadZones(); this.notify('Zone đã bị xoá'); } catch {} }, // ── Files ── async loadFiles() { if (!this.currentZone) return; this.filesLoading = true; try { this.files = await this.api(`/api/zones/${this.currentZone}/files?path=${encodeURIComponent(this.currentPath)}`); } catch { this.files = []; } this.filesLoading = false; }, navigateTo(path) { this.currentPath = path; this.loadFiles(); }, navigateUp() { const parts = this.currentPath.split('/').filter(Boolean); parts.pop(); this.currentPath = parts.join('/'); this.loadFiles(); }, joinPath(base, name) { return base ? `${base}/${name}` : name; }, async openFile(path) { if (this.editorDirty && !confirm('Bạn có thay đổi chưa lưu. Bỏ qua?')) return; try { const data = await this.api(`/api/zones/${this.currentZone}/files/read?path=${encodeURIComponent(path)}`); this.editorFile = path; this.editorContent = data.content; this.editorOriginal = data.content; this.editorDirty = false; this.activeTab = 'editor'; } catch {} }, async saveFile() { if (!this.editorFile || !this.editorDirty) return; const form = new FormData(); form.append('path', this.editorFile); form.append('content', this.editorContent); try { await this.api(`/api/zones/${this.currentZone}/files/write`, { method: 'POST', body: form }); this.editorOriginal = this.editorContent; this.editorDirty = false; this.notify('Đã lưu'); } catch {} }, async createFile() { if (!this.newFileName.trim()) return; const path = this.joinPath(this.currentPath, this.newFileName.trim()); const form = new FormData(); form.append('path', path); form.append('content', ''); try { await this.api(`/api/zones/${this.currentZone}/files/write`, { method: 'POST', body: form }); this.newFileName = ''; this.showNewFile = false; await this.loadFiles(); } catch {} }, async createFolder() { if (!this.newFolderName.trim()) return; const path = this.joinPath(this.currentPath, this.newFolderName.trim()); const form = new FormData(); form.append('path', path); try { await this.api(`/api/zones/${this.currentZone}/files/mkdir`, { method: 'POST', body: form }); this.newFolderName = ''; this.showNewFolder = false; await this.loadFiles(); } catch {} }, async uploadFile(event) { const fileList = event.target.files; if (!fileList || fileList.length === 0) return; for (const file of fileList) { const form = new FormData(); form.append('path', this.currentPath); form.append('file', file); try { await this.api(`/api/zones/${this.currentZone}/files/upload`, { method: 'POST', body: form }); } catch {} } event.target.value = ''; await this.loadFiles(); this.notify(`Đã upload ${fileList.length} file`); }, async deleteFile(path, isDir) { const label = isDir ? 'thư mục' : 'file'; if (!confirm(`Xoá ${label} "${path}"?`)) return; try { await this.api(`/api/zones/${this.currentZone}/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' }); if (this.editorFile === path) { this.editorFile = null; this.editorDirty = false; } await this.loadFiles(); } catch {} }, async downloadFile(path, name) { try { const resp = await fetch( `/api/zones/${this.currentZone}/files/download?path=${encodeURIComponent(path)}`, { headers: this.token ? { 'Authorization': `Bearer ${this.token}` } : {} } ); if (!resp.ok) throw new Error('Download failed'); const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url); } catch (err) { this.notify(err.message, 'error'); } }, startRename(file) { this.renameOldPath = this.joinPath(this.currentPath, file.name); this.renameNewName = file.name; this.showRename = true; }, async doRename() { if (!this.renameNewName.trim()) return; const form = new FormData(); form.append('old_path', this.renameOldPath); form.append('new_name', this.renameNewName.trim()); try { await this.api(`/api/zones/${this.currentZone}/files/rename`, { method: 'POST', body: form }); this.showRename = false; await this.loadFiles(); } catch {} }, getFileIcon(name) { const ext = name.split('.').pop()?.toLowerCase(); const map = { js: 'file-code', ts: 'file-code', py: 'file-code', go: 'file-code', html: 'file-code', css: 'file-code', json: 'file-json', md: 'file-text', txt: 'file-text', log: 'file-text', jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', svg: 'image', zip: 'file-archive', tar: 'file-archive', gz: 'file-archive', }; return map[ext] || 'file'; }, formatSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }, // ── Terminal ── initTerminal() { if (!this.currentZone) return; // Already connected to same zone if (this.termZone === this.currentZone && this.term) { this.$nextTick(() => this.termFit?.fit()); return; } this.disconnectTerminal(); const container = document.getElementById('terminal-container'); if (!container) return; container.innerHTML = ''; this.term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", theme: { background: '#000000', foreground: '#e4e4e7', cursor: '#8b5cf6', selectionBackground: '#8b5cf644', black: '#18181b', red: '#ef4444', green: '#22c55e', yellow: '#eab308', blue: '#3b82f6', magenta: '#a855f7', cyan: '#06b6d4', white: '#e4e4e7', }, allowProposedApi: true, }); this.termFit = new FitAddon.FitAddon(); const webLinks = new WebLinksAddon.WebLinksAddon(); this.term.loadAddon(this.termFit); this.term.loadAddon(webLinks); this.term.open(container); this.termFit.fit(); this.termZone = this.currentZone; // WebSocket const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${proto}//${location.host}/ws/terminal/${this.currentZone}?token=${encodeURIComponent(this.token || '')}`; this.termWs = new WebSocket(wsUrl); this.termWs.binaryType = 'arraybuffer'; this.termWs.onopen = () => { this.term.onData((data) => { if (this.termWs?.readyState === WebSocket.OPEN) { this.termWs.send(JSON.stringify({ type: 'input', data })); } }); this.term.onResize(({ rows, cols }) => { if (this.termWs?.readyState === WebSocket.OPEN) { this.termWs.send(JSON.stringify({ type: 'resize', rows, cols })); } }); // Send initial size const dims = this.termFit.proposeDimensions(); if (dims) { this.termWs.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols })); } }; this.termWs.onmessage = (e) => { if (e.data instanceof ArrayBuffer) { this.term.write(new Uint8Array(e.data)); } else { this.term.write(e.data); } }; this.termWs.onclose = () => { this.term?.write('\r\n\x1b[90m[Disconnected]\x1b[0m\r\n'); }; // Resize handler this._resizeHandler = () => this.termFit?.fit(); window.addEventListener('resize', this._resizeHandler); // ResizeObserver for container this._resizeObserver = new ResizeObserver(() => this.termFit?.fit()); this._resizeObserver.observe(container); }, disconnectTerminal() { if (this.termWs) { this.termWs.close(); this.termWs = null; } if (this.term) { this.term.dispose(); this.term = null; } if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler = null; } if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } this.termFit = null; this.termZone = null; }, // ── Ports ── async loadPorts() { if (!this.currentZone) return; try { this.ports = await this.api(`/api/zones/${this.currentZone}/ports`); } catch { this.ports = []; } }, async addPort() { if (!this.newPort) return; const form = new FormData(); form.append('port', this.newPort); form.append('label', this.newPortLabel); try { await this.api(`/api/zones/${this.currentZone}/ports`, { method: 'POST', body: form }); this.newPort = null; this.newPortLabel = ''; await this.loadPorts(); this.notify('Port đã được thêm'); } catch {} }, async removePort(port) { if (!confirm(`Xoá port ${port}?`)) return; try { await this.api(`/api/zones/${this.currentZone}/ports/${port}`, { method: 'DELETE' }); await this.loadPorts(); } catch {} }, // ── Backup ── async loadBackupStatus() { try { this.backupStatus = await this.api('/api/backup/status'); } catch {} }, async loadBackupList() { this.backupLoading = true; try { this.backupList = await this.api('/api/backup/list'); } catch { this.backupList = []; } this.backupLoading = false; }, async backupZone(zoneName) { if (!confirm(`Backup zone "${zoneName}" lên HuggingFace?`)) return; try { const res = await this.api(`/api/backup/zone/${zoneName}`, { method: 'POST' }); this.notify(res.message); this._pollBackupStatus(); } catch {} }, async backupAll() { if (!confirm('Backup tất cả zones lên HuggingFace?')) return; try { const res = await this.api('/api/backup/all', { method: 'POST' }); this.notify(res.message); this._pollBackupStatus(); } catch {} }, async restoreZone(zoneName) { if (!confirm(`Restore zone "${zoneName}" từ backup? Dữ liệu hiện tại sẽ bị ghi đè.`)) return; try { const res = await this.api(`/api/backup/restore/${zoneName}`, { method: 'POST' }); this.notify(res.message); this._pollBackupStatus(); } catch {} }, async restoreAll() { if (!confirm('Restore tất cả zones từ backup? Dữ liệu hiện tại sẽ bị ghi đè.')) return; try { const res = await this.api('/api/backup/restore-all', { method: 'POST' }); this.notify(res.message); this._pollBackupStatus(); } catch {} }, _pollBackupStatus() { if (this._pollTimer) return; this._pollTimer = setInterval(async () => { await this.loadBackupStatus(); if (!this.backupStatus.running) { clearInterval(this._pollTimer); this._pollTimer = null; await this.loadBackupList(); await this.loadZones(); if (this.backupStatus.error) { this.notify(this.backupStatus.error, 'error'); } else { this.notify(this.backupStatus.progress); } } }, 2000); }, }; }