| 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, | |
| backupFilterZone: '', | |
| // Create Zone | |
| showCreateZone: false, | |
| createZoneName: '', | |
| createZoneDesc: '', | |
| showEditZone: false, | |
| editZoneName: '', | |
| editZoneDesc: '', | |
| // Rename | |
| showRename: false, | |
| renameOldPath: '', | |
| renameNewName: '', | |
| // Toast | |
| toast: { show: false, message: '', type: 'info' }, | |
| // ── Computed ── | |
| get currentPathParts() { | |
| return this.currentPath ? this.currentPath.split('/').filter(Boolean) : []; | |
| }, | |
| get filteredBackupList() { | |
| if (!this.backupFilterZone) return this.backupList; | |
| return this.backupList.filter((item) => item.zone_name === this.backupFilterZone); | |
| }, | |
| // ── 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 | |
| this.syncAuthCookie(); | |
| 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('showEditZone', () => { | |
| this.$nextTick(() => { | |
| lucide.createIcons(); | |
| if (this.showEditZone) this.$refs.editZoneNameInput?.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); | |
| }, | |
| setAuthCookie(token) { | |
| const secure = location.protocol === 'https:' ? '; Secure' : ''; | |
| document.cookie = `token=${encodeURIComponent(token)}; Path=/; SameSite=Lax${secure}`; | |
| }, | |
| clearAuthCookie() { | |
| document.cookie = 'token=; Path=/; Max-Age=0; SameSite=Lax'; | |
| }, | |
| syncAuthCookie() { | |
| if (this.token) this.setAuthCookie(this.token); | |
| else this.clearAuthCookie(); | |
| }, | |
| // ── 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); | |
| this.syncAuthCookie(); | |
| 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); | |
| this.syncAuthCookie(); | |
| 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; | |
| this.clearAuthCookie(); | |
| 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 {} | |
| }, | |
| startEditZone(zone = null) { | |
| const current = zone || this.zones.find((item) => item.name === this.currentZone); | |
| if (!current) return; | |
| this.editZoneName = current.name || ''; | |
| this.editZoneDesc = current.description || ''; | |
| this.showEditZone = true; | |
| }, | |
| async saveZoneSettings() { | |
| if (!this.currentZone) return; | |
| const form = new FormData(); | |
| form.append('new_name', this.editZoneName.trim()); | |
| form.append('description', this.editZoneDesc.trim()); | |
| try { | |
| const data = await this.api(`/api/zones/${this.currentZone}`, { method: 'PATCH', body: form }); | |
| const previous = this.currentZone; | |
| this.currentZone = data.name; | |
| this.showEditZone = false; | |
| await this.loadZones(); | |
| if (previous !== data.name) { | |
| await this.selectZone(data.name); | |
| } else { | |
| await this.loadFiles(); | |
| } | |
| this.notify('Đã cập nhật zone'); | |
| } 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]; | |
| }, | |
| latestBackupForZone(zoneName) { | |
| return this.backupList.find((item) => item.zone_name === zoneName) || null; | |
| }, | |
| zoneBackupCount(zoneName) { | |
| return this.backupList.filter((item) => item.zone_name === zoneName).length; | |
| }, | |
| buildPortUrl(port) { | |
| const base = port?.url || (`/port/${this.currentZone}/${port?.port}/`); | |
| return new URL(base, location.origin).toString(); | |
| }, | |
| async copyText(value, message = 'Đã copy') { | |
| try { | |
| await navigator.clipboard.writeText(value); | |
| this.notify(message); | |
| } catch { | |
| this.notify('Không thể copy', 'error'); | |
| } | |
| }, | |
| // ── 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, backupName = null) { | |
| if (!confirm(`Restore zone "${zoneName}" từ backup? Dữ liệu hiện tại sẽ bị ghi đè.`)) return; | |
| try { | |
| const query = backupName ? `?backup_name=${encodeURIComponent(backupName)}` : ''; | |
| const res = await this.api(`/api/backup/restore/${zoneName}${query}`, { 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 {} | |
| }, | |
| async deleteBackup(backupName) { | |
| if (!confirm(`Xoá backup "${backupName}" trên cloud?`)) return; | |
| try { | |
| await this.api(`/api/backup/file?backup_name=${encodeURIComponent(backupName)}`, { method: 'DELETE' }); | |
| await this.loadBackupList(); | |
| this.notify('Đã xoá backup'); | |
| } 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); | |
| }, | |
| }; | |
| } |
Xet Storage Details
- Size:
- 26.2 kB
- Xet hash:
- ebab438fff9e06d67892a53f986fbf4fa7e4797568eda7716bce0a2439939929
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.