| function hugpanel() {
|
| return {
|
|
|
| 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: '' },
|
|
|
|
|
| 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: [],
|
| currentPath: '',
|
| filesLoading: false,
|
| showNewFile: false,
|
| showNewFolder: false,
|
| newFileName: '',
|
| newFolderName: '',
|
|
|
|
|
| editorFile: null,
|
| editorContent: '',
|
| editorOriginal: '',
|
| editorDirty: false,
|
|
|
|
|
| term: null,
|
| termWs: null,
|
| termFit: null,
|
| termZone: null,
|
|
|
|
|
| ports: [],
|
| newPort: null,
|
| newPortLabel: '',
|
|
|
|
|
| backupStatus: { configured: false, admin_url: null, running: false, last: null, error: null, progress: '' },
|
| backupList: [],
|
| backupLoading: false,
|
|
|
|
|
| showCreateZone: false,
|
| createZoneName: '',
|
| createZoneDesc: '',
|
|
|
|
|
| showRename: false,
|
| renameOldPath: '',
|
| renameNewName: '',
|
|
|
|
|
| toast: { show: false, message: '', type: 'info' },
|
|
|
|
|
| get currentPathParts() {
|
| return this.currentPath ? this.currentPath.split('/').filter(Boolean) : [];
|
| },
|
|
|
|
|
| async init() {
|
|
|
| await this.loadBackupStatus();
|
| if (this.backupStatus.admin_url) {
|
| this.adminApiUrl = this.backupStatus.admin_url;
|
| localStorage.setItem('hugpanel_admin_url', this.adminApiUrl);
|
| }
|
|
|
|
|
| await this._loadZoneLimit();
|
|
|
|
|
| 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 {
|
|
|
| this.token = null;
|
| localStorage.removeItem('hugpanel_token');
|
| }
|
| } catch {
|
|
|
| }
|
| } else if (!this.adminApiUrl) {
|
|
|
| } else {
|
|
|
| this.token = null;
|
| }
|
|
|
| this.authLoading = false;
|
|
|
| if (this.user) {
|
| await this._loadPanel();
|
| }
|
|
|
| this.$nextTick(() => lucide.createIcons());
|
|
|
|
|
| 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();
|
| });
|
| });
|
|
|
|
|
| const mql = window.matchMedia('(min-width: 1024px)');
|
| mql.addEventListener('change', (e) => { this.isDesktop = e.matches; });
|
|
|
|
|
| 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));
|
|
|
|
|
| document.addEventListener('keydown', (e) => {
|
| if (e.ctrlKey && e.key === 's' && this.activeTab === 'editor') {
|
| e.preventDefault();
|
| this.saveFile();
|
| }
|
| });
|
| },
|
|
|
|
|
| notify(message, type = 'info') {
|
| this.toast = { show: true, message, type };
|
| setTimeout(() => { this.toast.show = false; }, 3000);
|
| },
|
|
|
|
|
| async api(url, options = {}) {
|
| try {
|
| const headers = options.headers || {};
|
|
|
| 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;
|
| }
|
| },
|
|
|
|
|
| async _loadPanel() {
|
| await this.loadZones();
|
| await this.loadBackupStatus();
|
|
|
| if (this.currentZone && this.zones.some(z => z.name === this.currentZone)) {
|
| await this.selectZone(this.currentZone);
|
| } else {
|
| this.currentZone = null;
|
| }
|
|
|
| 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();
|
| },
|
|
|
|
|
| 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 {}
|
| },
|
|
|
|
|
| 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];
|
| },
|
|
|
|
|
| initTerminal() {
|
| if (!this.currentZone) return;
|
|
|
|
|
| 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;
|
|
|
|
|
| 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 }));
|
| }
|
| });
|
|
|
| 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');
|
| };
|
|
|
|
|
| this._resizeHandler = () => this.termFit?.fit();
|
| window.addEventListener('resize', this._resizeHandler);
|
|
|
|
|
| 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;
|
| },
|
|
|
|
|
| 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 {}
|
| },
|
|
|
|
|
| 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);
|
| },
|
| };
|
| } |