kokokoasd commited on
Commit
30117ef
·
verified ·
1 Parent(s): 8e4fdb0

Upload 20 files

Browse files
Files changed (6) hide show
  1. Dockerfile +1 -0
  2. config.py +7 -0
  3. requirements.txt +1 -0
  4. routers/backup.py +301 -0
  5. static/app.js +204 -2
  6. static/index.html +193 -1
Dockerfile CHANGED
@@ -16,6 +16,7 @@ RUN wget -q https://go.dev/dl/go1.22.5.linux-amd64.tar.gz \
16
  && rm go1.22.5.linux-amd64.tar.gz
17
  ENV PATH="/usr/local/go/bin:${PATH}"
18
  ENV GOPATH="/data/go"
 
19
 
20
  # Tạo user (HF Spaces yêu cầu user 1000)
21
  RUN useradd -m -u 1000 user
 
16
  && rm go1.22.5.linux-amd64.tar.gz
17
  ENV PATH="/usr/local/go/bin:${PATH}"
18
  ENV GOPATH="/data/go"
19
+ ENV ADMIN_API_URL=""
20
 
21
  # Tạo user (HF Spaces yêu cầu user 1000)
22
  RUN useradd -m -u 1000 user
config.py CHANGED
@@ -24,3 +24,10 @@ MAX_PORT = 65535
24
 
25
  # ── Terminal ───────────────────────────────────
26
  SCROLLBACK_SIZE = 128 * 1024 # 128 KB
 
 
 
 
 
 
 
 
24
 
25
  # ── Terminal ───────────────────────────────────
26
  SCROLLBACK_SIZE = 128 * 1024 # 128 KB
27
+
28
+ # ── Admin API (Cloudflare Worker) ──────────────
29
+ ADMIN_API_URL = os.environ.get("ADMIN_API_URL", "") # e.g. "https://hugpanel-admin.username.workers.dev"
30
+
31
+ # ── Backup (local temp dir) ────────────────────
32
+ BACKUP_DIR = DATA_DIR.parent / "backups"
33
+ BACKUP_DIR.mkdir(parents=True, exist_ok=True)
requirements.txt CHANGED
@@ -4,3 +4,4 @@ websockets==14.1
4
  python-multipart==0.0.18
5
  aiofiles==24.1.0
6
  httpx==0.28.1
 
 
4
  python-multipart==0.0.18
5
  aiofiles==24.1.0
6
  httpx==0.28.1
7
+
routers/backup.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Backup & Restore API - proxies through the Admin Worker (Cloudflare Worker).
3
+
4
+ The Admin Worker handles HuggingFace Dataset storage with per-user folders.
5
+ This router creates local tar.gz archives and sends them to the Worker,
6
+ or downloads archives from the Worker and extracts them locally.
7
+
8
+ Requires env var:
9
+ ADMIN_API_URL - URL of the Cloudflare Worker admin API
10
+ """
11
+
12
+ import os
13
+ import shutil
14
+ import tarfile
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ import httpx
19
+ from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
20
+
21
+ from config import DATA_DIR, ADMIN_API_URL, BACKUP_DIR
22
+ from storage import load_meta, save_meta, validate_zone_name
23
+
24
+ router = APIRouter(prefix="/api/backup", tags=["backup"])
25
+
26
+
27
+ def _get_token(request: Request) -> str:
28
+ """Extract JWT token from request Authorization header."""
29
+ auth = request.headers.get("Authorization", "")
30
+ if auth.startswith("Bearer "):
31
+ return auth[7:]
32
+ return ""
33
+
34
+
35
+ def _worker_headers(token: str) -> dict:
36
+ """Build headers for Worker API calls."""
37
+ return {"Authorization": f"Bearer {token}"}
38
+
39
+
40
+ def _create_zone_archive(zone_name: str) -> Path:
41
+ """Create a tar.gz archive of a zone directory."""
42
+ zone_path = DATA_DIR / zone_name
43
+ if not zone_path.is_dir():
44
+ raise ValueError(f"Zone '{zone_name}' khong ton tai")
45
+
46
+ archive_path = BACKUP_DIR / f"{zone_name}.tar.gz"
47
+ with tarfile.open(archive_path, "w:gz") as tar:
48
+ tar.add(str(zone_path), arcname=zone_name)
49
+ return archive_path
50
+
51
+
52
+ _backup_status: dict = {"running": False, "last": None, "error": None, "progress": ""}
53
+
54
+
55
+ @router.get("/status")
56
+ def backup_status():
57
+ return {
58
+ "configured": bool(ADMIN_API_URL),
59
+ "admin_url": ADMIN_API_URL or None,
60
+ "running": _backup_status["running"],
61
+ "last": _backup_status["last"],
62
+ "error": _backup_status["error"],
63
+ "progress": _backup_status["progress"],
64
+ }
65
+
66
+
67
+ @router.get("/list")
68
+ async def list_backups(request: Request):
69
+ if not ADMIN_API_URL:
70
+ raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
71
+ token = _get_token(request)
72
+ if not token:
73
+ raise HTTPException(401, "Chua dang nhap")
74
+ try:
75
+ async with httpx.AsyncClient(timeout=30) as client:
76
+ resp = await client.get(f"{ADMIN_API_URL}/backup/list", headers=_worker_headers(token))
77
+ if resp.status_code != 200:
78
+ data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {"error": resp.text}
79
+ raise HTTPException(resp.status_code, data.get("error", "Worker error"))
80
+ return resp.json()
81
+ except httpx.HTTPError as e:
82
+ raise HTTPException(502, f"Khong the ket noi Worker: {e}")
83
+
84
+
85
+ @router.post("/zone/{zone_name}")
86
+ async def backup_zone(zone_name: str, request: Request, background_tasks: BackgroundTasks):
87
+ if not ADMIN_API_URL:
88
+ raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
89
+ token = _get_token(request)
90
+ if not token:
91
+ raise HTTPException(401, "Chua dang nhap")
92
+ try:
93
+ validate_zone_name(zone_name)
94
+ if not (DATA_DIR / zone_name).is_dir():
95
+ raise ValueError(f"Zone '{zone_name}' khong ton tai")
96
+ except ValueError as e:
97
+ raise HTTPException(400, str(e))
98
+ if _backup_status["running"]:
99
+ raise HTTPException(409, "Dang co backup khac dang chay")
100
+
101
+ def _run():
102
+ _backup_status["running"] = True
103
+ _backup_status["error"] = None
104
+ _backup_status["progress"] = f"Dang backup zone: {zone_name}..."
105
+ try:
106
+ archive_path = _create_zone_archive(zone_name)
107
+ try:
108
+ with httpx.Client(timeout=300) as client:
109
+ with open(archive_path, "rb") as f:
110
+ resp = client.post(
111
+ f"{ADMIN_API_URL}/backup/upload/{zone_name}",
112
+ headers={**_worker_headers(token), "Content-Type": "application/octet-stream"},
113
+ content=f.read(),
114
+ )
115
+ if resp.status_code != 200:
116
+ raise ValueError(f"Worker error: {resp.text}")
117
+ finally:
118
+ archive_path.unlink(missing_ok=True)
119
+ _backup_status["last"] = datetime.now().isoformat()
120
+ _backup_status["progress"] = f"Backup zone {zone_name} thanh cong"
121
+ except Exception as e:
122
+ _backup_status["error"] = str(e)
123
+ _backup_status["progress"] = f"Loi backup: {e}"
124
+ finally:
125
+ _backup_status["running"] = False
126
+
127
+ background_tasks.add_task(_run)
128
+ return {"ok": True, "message": f"Dang backup zone {zone_name} trong nen..."}
129
+
130
+
131
+ @router.post("/all")
132
+ async def backup_all(request: Request, background_tasks: BackgroundTasks):
133
+ if not ADMIN_API_URL:
134
+ raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
135
+ token = _get_token(request)
136
+ if not token:
137
+ raise HTTPException(401, "Chua dang nhap")
138
+ if _backup_status["running"]:
139
+ raise HTTPException(409, "Dang co backup khac dang chay")
140
+
141
+ def _run():
142
+ _backup_status["running"] = True
143
+ _backup_status["error"] = None
144
+ _backup_status["progress"] = "Dang backup tat ca zones..."
145
+ try:
146
+ meta = load_meta()
147
+ total = len(meta)
148
+ done = 0
149
+ for zone_name in meta:
150
+ zone_path = DATA_DIR / zone_name
151
+ if not zone_path.is_dir():
152
+ continue
153
+ _backup_status["progress"] = f"Dang backup zone {zone_name} ({done + 1}/{total})..."
154
+ archive_path = _create_zone_archive(zone_name)
155
+ try:
156
+ with httpx.Client(timeout=300) as client:
157
+ with open(archive_path, "rb") as f:
158
+ resp = client.post(
159
+ f"{ADMIN_API_URL}/backup/upload/{zone_name}",
160
+ headers={**_worker_headers(token), "Content-Type": "application/octet-stream"},
161
+ content=f.read(),
162
+ )
163
+ if resp.status_code != 200:
164
+ raise ValueError(f"Worker error for {zone_name}: {resp.text}")
165
+ finally:
166
+ archive_path.unlink(missing_ok=True)
167
+ done += 1
168
+ _backup_status["last"] = datetime.now().isoformat()
169
+ _backup_status["progress"] = "Backup tat ca zones thanh cong"
170
+ except Exception as e:
171
+ _backup_status["error"] = str(e)
172
+ _backup_status["progress"] = f"Loi backup: {e}"
173
+ finally:
174
+ _backup_status["running"] = False
175
+
176
+ background_tasks.add_task(_run)
177
+ return {"ok": True, "message": "Dang backup tat ca zones trong nen..."}
178
+
179
+
180
+ @router.post("/restore/{zone_name}")
181
+ async def restore_zone(zone_name: str, request: Request, background_tasks: BackgroundTasks):
182
+ if not ADMIN_API_URL:
183
+ raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
184
+ token = _get_token(request)
185
+ if not token:
186
+ raise HTTPException(401, "Chua dang nhap")
187
+ try:
188
+ validate_zone_name(zone_name)
189
+ except ValueError as e:
190
+ raise HTTPException(400, str(e))
191
+ if _backup_status["running"]:
192
+ raise HTTPException(409, "Dang co backup/restore khac dang chay")
193
+
194
+ def _run():
195
+ _backup_status["running"] = True
196
+ _backup_status["error"] = None
197
+ _backup_status["progress"] = f"Dang restore zone: {zone_name}..."
198
+ try:
199
+ with httpx.Client(timeout=300) as client:
200
+ resp = client.get(f"{ADMIN_API_URL}/backup/download/{zone_name}", headers=_worker_headers(token))
201
+ if resp.status_code == 404:
202
+ raise ValueError(f"Backup zone '{zone_name}' khong ton tai")
203
+ if resp.status_code != 200:
204
+ raise ValueError(f"Worker error: {resp.text}")
205
+ archive_path = BACKUP_DIR / f"{zone_name}.tar.gz"
206
+ archive_path.write_bytes(resp.content)
207
+
208
+ try:
209
+ zone_path = DATA_DIR / zone_name
210
+ if zone_path.exists():
211
+ shutil.rmtree(zone_path)
212
+ with tarfile.open(archive_path, "r:gz") as tar:
213
+ for member in tar.getmembers():
214
+ member_path = os.path.normpath(member.name)
215
+ if member_path.startswith("..") or os.path.isabs(member_path):
216
+ raise ValueError(f"Archive chua path khong an toan: {member.name}")
217
+ if not member_path.startswith(zone_name):
218
+ raise ValueError(f"Archive chua path ngoai zone: {member.name}")
219
+ tar.extractall(path=str(DATA_DIR), filter="data")
220
+ meta = load_meta()
221
+ if zone_name not in meta:
222
+ meta[zone_name] = {"description": "", "created": datetime.now().isoformat()}
223
+ save_meta(meta)
224
+ finally:
225
+ archive_path.unlink(missing_ok=True)
226
+
227
+ _backup_status["last"] = datetime.now().isoformat()
228
+ _backup_status["progress"] = f"Restore zone {zone_name} thanh cong"
229
+ except Exception as e:
230
+ _backup_status["error"] = str(e)
231
+ _backup_status["progress"] = f"Loi restore: {e}"
232
+ finally:
233
+ _backup_status["running"] = False
234
+
235
+ background_tasks.add_task(_run)
236
+ return {"ok": True, "message": f"Dang restore zone {zone_name} trong nen..."}
237
+
238
+
239
+ @router.post("/restore-all")
240
+ async def restore_all(request: Request, background_tasks: BackgroundTasks):
241
+ if not ADMIN_API_URL:
242
+ raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
243
+ token = _get_token(request)
244
+ if not token:
245
+ raise HTTPException(401, "Chua dang nhap")
246
+ if _backup_status["running"]:
247
+ raise HTTPException(409, "Dang co backup/restore khac dang chay")
248
+
249
+ def _run():
250
+ _backup_status["running"] = True
251
+ _backup_status["error"] = None
252
+ _backup_status["progress"] = "Dang restore tat ca zones..."
253
+ try:
254
+ with httpx.Client(timeout=30) as client:
255
+ resp = client.get(f"{ADMIN_API_URL}/backup/list", headers=_worker_headers(token))
256
+ if resp.status_code != 200:
257
+ raise ValueError(f"Khong the lay danh sach backup: {resp.text}")
258
+ backup_list = resp.json()
259
+
260
+ total = len(backup_list)
261
+ done = 0
262
+ for b in backup_list:
263
+ zone_name = b["zone_name"]
264
+ _backup_status["progress"] = f"Dang restore zone {zone_name} ({done + 1}/{total})..."
265
+ with httpx.Client(timeout=300) as client:
266
+ resp = client.get(f"{ADMIN_API_URL}/backup/download/{zone_name}", headers=_worker_headers(token))
267
+ if resp.status_code != 200:
268
+ continue
269
+ archive_path = BACKUP_DIR / f"{zone_name}.tar.gz"
270
+ archive_path.write_bytes(resp.content)
271
+
272
+ try:
273
+ zone_path = DATA_DIR / zone_name
274
+ if zone_path.exists():
275
+ shutil.rmtree(zone_path)
276
+ with tarfile.open(archive_path, "r:gz") as tar:
277
+ for member in tar.getmembers():
278
+ member_path = os.path.normpath(member.name)
279
+ if member_path.startswith("..") or os.path.isabs(member_path):
280
+ raise ValueError(f"Archive chua path khong an toan: {member.name}")
281
+ if not member_path.startswith(zone_name):
282
+ raise ValueError(f"Archive chua path ngoai zone: {member.name}")
283
+ tar.extractall(path=str(DATA_DIR), filter="data")
284
+ meta = load_meta()
285
+ if zone_name not in meta:
286
+ meta[zone_name] = {"description": "", "created": datetime.now().isoformat()}
287
+ save_meta(meta)
288
+ finally:
289
+ archive_path.unlink(missing_ok=True)
290
+ done += 1
291
+
292
+ _backup_status["last"] = datetime.now().isoformat()
293
+ _backup_status["progress"] = f"Restore {done}/{total} zones thanh cong"
294
+ except Exception as e:
295
+ _backup_status["error"] = str(e)
296
+ _backup_status["progress"] = f"Loi restore: {e}"
297
+ finally:
298
+ _backup_status["running"] = False
299
+
300
+ background_tasks.add_task(_run)
301
+ return {"ok": True, "message": "Dang restore tat ca zones trong nen..."}
static/app.js CHANGED
@@ -1,5 +1,16 @@
1
  function hugpanel() {
2
  return {
 
 
 
 
 
 
 
 
 
 
 
3
  // ── State ──
4
  sidebarOpen: false,
5
  zones: [],
@@ -10,6 +21,7 @@ function hugpanel() {
10
  { id: 'editor', label: 'Editor', icon: 'file-code' },
11
  { id: 'terminal', label: 'Terminal', icon: 'terminal' },
12
  { id: 'ports', label: 'Ports', icon: 'radio' },
 
13
  ],
14
 
15
  // Files
@@ -38,6 +50,11 @@ function hugpanel() {
38
  newPort: null,
39
  newPortLabel: '',
40
 
 
 
 
 
 
41
  // Create Zone
42
  showCreateZone: false,
43
  createZoneName: '',
@@ -58,7 +75,39 @@ function hugpanel() {
58
 
59
  // ── Init ──
60
  async init() {
61
- await this.loadZones();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  this.$nextTick(() => lucide.createIcons());
63
 
64
  // Watch for icon updates
@@ -67,6 +116,8 @@ function hugpanel() {
67
  this.$watch('activeTab', () => this.$nextTick(() => lucide.createIcons()));
68
  this.$watch('currentZone', () => this.$nextTick(() => lucide.createIcons()));
69
  this.$watch('ports', () => this.$nextTick(() => lucide.createIcons()));
 
 
70
  this.$watch('showCreateZone', () => {
71
  this.$nextTick(() => {
72
  lucide.createIcons();
@@ -104,7 +155,12 @@ function hugpanel() {
104
  // ── API Helper ──
105
  async api(url, options = {}) {
106
  try {
107
- const resp = await fetch(url, options);
 
 
 
 
 
108
  if (!resp.ok) {
109
  const data = await resp.json().catch(() => ({ detail: resp.statusText }));
110
  throw new Error(data.detail || resp.statusText);
@@ -116,6 +172,80 @@ function hugpanel() {
116
  }
117
  },
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  // ── Zones ──
120
  async loadZones() {
121
  try {
@@ -132,6 +262,9 @@ function hugpanel() {
132
  this.disconnectTerminal();
133
  await this.loadFiles();
134
  await this.loadPorts();
 
 
 
135
  },
136
 
137
  async createZone() {
@@ -453,5 +586,74 @@ function hugpanel() {
453
  await this.loadPorts();
454
  } catch {}
455
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  };
457
  }
 
1
  function hugpanel() {
2
  return {
3
+ // ── Auth State ──
4
+ user: null,
5
+ token: localStorage.getItem('hugpanel_token'),
6
+ adminApiUrl: '',
7
+ authLoading: true,
8
+ authMode: 'login',
9
+ authError: '',
10
+ authSubmitting: false,
11
+ loginForm: { username: '', password: '' },
12
+ registerForm: { username: '', email: '', password: '' },
13
+
14
  // ── State ──
15
  sidebarOpen: false,
16
  zones: [],
 
21
  { id: 'editor', label: 'Editor', icon: 'file-code' },
22
  { id: 'terminal', label: 'Terminal', icon: 'terminal' },
23
  { id: 'ports', label: 'Ports', icon: 'radio' },
24
+ { id: 'backup', label: 'Backup', icon: 'cloud' },
25
  ],
26
 
27
  // Files
 
50
  newPort: null,
51
  newPortLabel: '',
52
 
53
+ // Backup
54
+ backupStatus: { configured: false, admin_url: null, running: false, last: null, error: null, progress: '' },
55
+ backupList: [],
56
+ backupLoading: false,
57
+
58
  // Create Zone
59
  showCreateZone: false,
60
  createZoneName: '',
 
75
 
76
  // ── Init ──
77
  async init() {
78
+ // Load backup status to get adminApiUrl
79
+ await this.loadBackupStatus();
80
+ this.adminApiUrl = this.backupStatus.admin_url || '';
81
+
82
+ // Try to restore session from stored token
83
+ if (this.token && this.adminApiUrl) {
84
+ try {
85
+ const resp = await fetch(`${this.adminApiUrl}/auth/me`, {
86
+ headers: { 'Authorization': `Bearer ${this.token}` },
87
+ });
88
+ if (resp.ok) {
89
+ const data = await resp.json();
90
+ this.user = data.user;
91
+ } else {
92
+ this.token = null;
93
+ localStorage.removeItem('hugpanel_token');
94
+ }
95
+ } catch {
96
+ // Worker unreachable — clear token
97
+ this.token = null;
98
+ localStorage.removeItem('hugpanel_token');
99
+ }
100
+ } else {
101
+ this.token = null;
102
+ localStorage.removeItem('hugpanel_token');
103
+ }
104
+
105
+ this.authLoading = false;
106
+
107
+ if (this.user) {
108
+ await this._loadPanel();
109
+ }
110
+
111
  this.$nextTick(() => lucide.createIcons());
112
 
113
  // Watch for icon updates
 
116
  this.$watch('activeTab', () => this.$nextTick(() => lucide.createIcons()));
117
  this.$watch('currentZone', () => this.$nextTick(() => lucide.createIcons()));
118
  this.$watch('ports', () => this.$nextTick(() => lucide.createIcons()));
119
+ this.$watch('backupList', () => this.$nextTick(() => lucide.createIcons()));
120
+ this.$watch('backupStatus', () => this.$nextTick(() => lucide.createIcons()));
121
  this.$watch('showCreateZone', () => {
122
  this.$nextTick(() => {
123
  lucide.createIcons();
 
155
  // ── API Helper ──
156
  async api(url, options = {}) {
157
  try {
158
+ const headers = options.headers || {};
159
+ // Add JWT token for backup API calls (proxied to Worker)
160
+ if (this.token && url.startsWith('/api/backup')) {
161
+ headers['Authorization'] = `Bearer ${this.token}`;
162
+ }
163
+ const resp = await fetch(url, { ...options, headers: { ...headers, ...options.headers } });
164
  if (!resp.ok) {
165
  const data = await resp.json().catch(() => ({ detail: resp.statusText }));
166
  throw new Error(data.detail || resp.statusText);
 
172
  }
173
  },
174
 
175
+ // ── Auth ──
176
+ async _loadPanel() {
177
+ await this.loadZones();
178
+ await this.loadBackupStatus();
179
+ },
180
+
181
+ async login() {
182
+ if (!this.adminApiUrl) {
183
+ this.authError = 'ADMIN_API_URL chưa cấu hình trên server';
184
+ return;
185
+ }
186
+ this.authError = '';
187
+ this.authSubmitting = true;
188
+ try {
189
+ const resp = await fetch(`${this.adminApiUrl}/auth/login`, {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify(this.loginForm),
193
+ });
194
+ const data = await resp.json();
195
+ if (!resp.ok) {
196
+ this.authError = data.error || 'Đăng nhập thất bại';
197
+ this.authSubmitting = false;
198
+ return;
199
+ }
200
+ this.token = data.token;
201
+ this.user = data.user;
202
+ localStorage.setItem('hugpanel_token', data.token);
203
+ await this._loadPanel();
204
+ this.$nextTick(() => lucide.createIcons());
205
+ } catch (err) {
206
+ this.authError = 'Không thể kết nối Admin Server';
207
+ }
208
+ this.authSubmitting = false;
209
+ },
210
+
211
+ async register() {
212
+ if (!this.adminApiUrl) {
213
+ this.authError = 'ADMIN_API_URL chưa cấu hình trên server';
214
+ return;
215
+ }
216
+ this.authError = '';
217
+ this.authSubmitting = true;
218
+ try {
219
+ const resp = await fetch(`${this.adminApiUrl}/auth/register`, {
220
+ method: 'POST',
221
+ headers: { 'Content-Type': 'application/json' },
222
+ body: JSON.stringify(this.registerForm),
223
+ });
224
+ const data = await resp.json();
225
+ if (!resp.ok) {
226
+ this.authError = data.error || 'Đăng ký thất bại';
227
+ this.authSubmitting = false;
228
+ return;
229
+ }
230
+ this.token = data.token;
231
+ this.user = data.user;
232
+ localStorage.setItem('hugpanel_token', data.token);
233
+ await this._loadPanel();
234
+ this.$nextTick(() => lucide.createIcons());
235
+ } catch (err) {
236
+ this.authError = 'Không thể kết nối Admin Server';
237
+ }
238
+ this.authSubmitting = false;
239
+ },
240
+
241
+ logout() {
242
+ this.token = null;
243
+ this.user = null;
244
+ localStorage.removeItem('hugpanel_token');
245
+ this.currentZone = null;
246
+ this.disconnectTerminal();
247
+ },
248
+
249
  // ── Zones ──
250
  async loadZones() {
251
  try {
 
262
  this.disconnectTerminal();
263
  await this.loadFiles();
264
  await this.loadPorts();
265
+ if (this.backupStatus.configured) {
266
+ await this.loadBackupList();
267
+ }
268
  },
269
 
270
  async createZone() {
 
586
  await this.loadPorts();
587
  } catch {}
588
  },
589
+
590
+ // ── Backup ──
591
+ async loadBackupStatus() {
592
+ try {
593
+ this.backupStatus = await this.api('/api/backup/status');
594
+ } catch {}
595
+ },
596
+
597
+ async loadBackupList() {
598
+ this.backupLoading = true;
599
+ try {
600
+ this.backupList = await this.api('/api/backup/list');
601
+ } catch { this.backupList = []; }
602
+ this.backupLoading = false;
603
+ },
604
+
605
+ async backupZone(zoneName) {
606
+ if (!confirm(`Backup zone "${zoneName}" lên HuggingFace?`)) return;
607
+ try {
608
+ const res = await this.api(`/api/backup/zone/${zoneName}`, { method: 'POST' });
609
+ this.notify(res.message);
610
+ this._pollBackupStatus();
611
+ } catch {}
612
+ },
613
+
614
+ async backupAll() {
615
+ if (!confirm('Backup tất cả zones lên HuggingFace?')) return;
616
+ try {
617
+ const res = await this.api('/api/backup/all', { method: 'POST' });
618
+ this.notify(res.message);
619
+ this._pollBackupStatus();
620
+ } catch {}
621
+ },
622
+
623
+ async restoreZone(zoneName) {
624
+ if (!confirm(`Restore zone "${zoneName}" từ backup? Dữ liệu hiện tại sẽ bị ghi đè.`)) return;
625
+ try {
626
+ const res = await this.api(`/api/backup/restore/${zoneName}`, { method: 'POST' });
627
+ this.notify(res.message);
628
+ this._pollBackupStatus();
629
+ } catch {}
630
+ },
631
+
632
+ async restoreAll() {
633
+ if (!confirm('Restore tất cả zones từ backup? Dữ liệu hiện tại sẽ bị ghi đè.')) return;
634
+ try {
635
+ const res = await this.api('/api/backup/restore-all', { method: 'POST' });
636
+ this.notify(res.message);
637
+ this._pollBackupStatus();
638
+ } catch {}
639
+ },
640
+
641
+ _pollBackupStatus() {
642
+ if (this._pollTimer) return;
643
+ this._pollTimer = setInterval(async () => {
644
+ await this.loadBackupStatus();
645
+ if (!this.backupStatus.running) {
646
+ clearInterval(this._pollTimer);
647
+ this._pollTimer = null;
648
+ await this.loadBackupList();
649
+ await this.loadZones();
650
+ if (this.backupStatus.error) {
651
+ this.notify(this.backupStatus.error, 'error');
652
+ } else {
653
+ this.notify(this.backupStatus.progress);
654
+ }
655
+ }
656
+ }, 2000);
657
+ },
658
  };
659
  }
static/index.html CHANGED
@@ -42,6 +42,63 @@
42
 
43
  <body class="h-full bg-gray-950 text-gray-100 overflow-hidden" x-data="hugpanel()" x-init="init()">
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  <!-- ═══ Mobile Top Bar ═══ -->
46
  <header class="lg:hidden fixed top-0 inset-x-0 z-50 bg-gray-900/95 backdrop-blur border-b border-gray-800 px-4 py-3 flex items-center justify-between">
47
  <button @click="sidebarOpen = !sidebarOpen" class="p-1.5 rounded-lg hover:bg-gray-800 transition">
@@ -94,13 +151,27 @@
94
  </div>
95
 
96
  <!-- Create Zone -->
97
- <div class="p-3 border-t border-gray-800">
98
  <button @click="showCreateZone = true"
99
  class="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg bg-brand-600 hover:bg-brand-500 text-white text-sm font-medium transition shadow-lg shadow-brand-600/25">
100
  <i data-lucide="plus" class="w-4 h-4"></i>
101
  Tạo Zone
102
  </button>
103
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  </aside>
105
 
106
  <!-- ═══ Main Content ═══ -->
@@ -324,10 +395,131 @@
324
  </div>
325
  </div>
326
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  </div>
328
  </main>
329
  </div>
330
 
 
 
331
  <!-- ═══ MODAL: Create Zone ═══ -->
332
  <div x-show="showCreateZone" x-transition:enter="transition duration-200" x-transition:leave="transition duration-150"
333
  class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
 
42
 
43
  <body class="h-full bg-gray-950 text-gray-100 overflow-hidden" x-data="hugpanel()" x-init="init()">
44
 
45
+ <!-- ═══ AUTH: Login / Register Screen ═══ -->
46
+ <div x-show="!user && !authLoading" x-transition class="min-h-full flex items-center justify-center p-4">
47
+ <div class="w-full max-w-sm">
48
+ <!-- Logo -->
49
+ <div class="text-center mb-6">
50
+ <div class="w-14 h-14 mx-auto mb-3 rounded-2xl bg-gradient-to-br from-brand-500 to-brand-700 flex items-center justify-center text-2xl font-bold shadow-lg shadow-brand-500/25">H</div>
51
+ <h1 class="text-xl font-bold">HugPanel</h1>
52
+ <p class="text-xs text-gray-500 mt-1">Workspace Manager</p>
53
+ </div>
54
+
55
+ <div class="bg-gray-900 rounded-2xl border border-gray-800 p-6 space-y-4">
56
+ <!-- Tab switch -->
57
+ <div class="flex bg-gray-800 rounded-lg p-0.5">
58
+ <button @click="authMode = 'login'" :class="authMode === 'login' ? 'bg-brand-600 text-white shadow' : 'text-gray-400 hover:text-gray-200'" class="flex-1 py-2 text-sm font-medium rounded-md transition">Đăng nhập</button>
59
+ <button @click="authMode = 'register'" :class="authMode === 'register' ? 'bg-brand-600 text-white shadow' : 'text-gray-400 hover:text-gray-200'" class="flex-1 py-2 text-sm font-medium rounded-md transition">Đăng ký</button>
60
+ </div>
61
+
62
+ <!-- Error -->
63
+ <div x-show="authError" class="text-xs text-red-400 bg-red-400/10 rounded-lg px-3 py-2" x-text="authError"></div>
64
+
65
+ <!-- Login Form -->
66
+ <div x-show="authMode === 'login'" class="space-y-3">
67
+ <input x-model="loginForm.username" placeholder="Username hoặc Email" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-brand-500 transition" @keydown.enter="login()" />
68
+ <input x-model="loginForm.password" type="password" placeholder="Mật khẩu" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-brand-500 transition" @keydown.enter="login()" />
69
+ <button @click="login()" :disabled="authSubmitting" class="w-full py-2.5 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition disabled:opacity-50">
70
+ <span x-show="!authSubmitting">Đăng nhập</span>
71
+ <span x-show="authSubmitting">Đang xử lý...</span>
72
+ </button>
73
+ </div>
74
+
75
+ <!-- Register Form -->
76
+ <div x-show="authMode === 'register'" class="space-y-3">
77
+ <input x-model="registerForm.username" placeholder="Username" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-brand-500 transition" @keydown.enter="register()" />
78
+ <input x-model="registerForm.email" type="email" placeholder="Email" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-brand-500 transition" @keydown.enter="register()" />
79
+ <input x-model="registerForm.password" type="password" placeholder="Mật khẩu (ít nhất 6 ký tự)" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-brand-500 transition" @keydown.enter="register()" />
80
+ <button @click="register()" :disabled="authSubmitting" class="w-full py-2.5 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-medium transition disabled:opacity-50">
81
+ <span x-show="!authSubmitting">Đăng ký</span>
82
+ <span x-show="authSubmitting">Đang xử lý...</span>
83
+ </button>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Admin API URL indicator -->
88
+ <div class="mt-4 text-center">
89
+ <div x-show="!adminApiUrl" class="text-xs text-yellow-500">ADMIN_API_URL chưa cấu hình</div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- Auth loading spinner -->
95
+ <div x-show="authLoading" class="min-h-full flex items-center justify-center">
96
+ <div class="w-8 h-8 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
97
+ </div>
98
+
99
+ <!-- ═══ MAIN PANEL (shown when logged in) ═══ -->
100
+ <div x-show="user" x-cloak>
101
+
102
  <!-- ═══ Mobile Top Bar ═══ -->
103
  <header class="lg:hidden fixed top-0 inset-x-0 z-50 bg-gray-900/95 backdrop-blur border-b border-gray-800 px-4 py-3 flex items-center justify-between">
104
  <button @click="sidebarOpen = !sidebarOpen" class="p-1.5 rounded-lg hover:bg-gray-800 transition">
 
151
  </div>
152
 
153
  <!-- Create Zone -->
154
+ <div class="p-3 border-t border-gray-800 space-y-2">
155
  <button @click="showCreateZone = true"
156
  class="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg bg-brand-600 hover:bg-brand-500 text-white text-sm font-medium transition shadow-lg shadow-brand-600/25">
157
  <i data-lucide="plus" class="w-4 h-4"></i>
158
  Tạo Zone
159
  </button>
160
  </div>
161
+
162
+ <!-- User Info + Logout -->
163
+ <div class="p-3 border-t border-gray-800">
164
+ <div class="flex items-center gap-2.5 px-2 py-1.5">
165
+ <div class="w-8 h-8 rounded-lg bg-gray-800 flex items-center justify-center text-xs font-bold text-brand-400" x-text="user?.username?.charAt(0).toUpperCase()"></div>
166
+ <div class="flex-1 min-w-0">
167
+ <div class="text-sm font-medium truncate" x-text="user?.username"></div>
168
+ <div class="text-xs text-gray-500" x-text="user?.role === 'admin' ? 'Admin' : 'User'"></div>
169
+ </div>
170
+ <button @click="logout()" class="p-1.5 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-400/10 transition" title="Đăng xuất">
171
+ <i data-lucide="log-out" class="w-4 h-4"></i>
172
+ </button>
173
+ </div>
174
+ </div>
175
  </aside>
176
 
177
  <!-- ═══ Main Content ═══ -->
 
395
  </div>
396
  </div>
397
  </div>
398
+
399
+ <!-- ═══ TAB: Backup ═══ -->
400
+ <div x-show="activeTab === 'backup'" x-effect="if(activeTab==='backup' && backupStatus.configured) loadBackupList()" class="flex-1 overflow-y-auto">
401
+ <div class="p-4 space-y-4">
402
+
403
+ <!-- Not configured -->
404
+ <div x-show="!backupStatus.configured" class="bg-gray-900 rounded-xl border border-gray-800 p-6 text-center">
405
+ <div class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-yellow-500/10 flex items-center justify-center">
406
+ <i data-lucide="cloud-off" class="w-7 h-7 text-yellow-500"></i>
407
+ </div>
408
+ <h3 class="text-sm font-semibold text-gray-300 mb-2">Chưa cấu hình Backup</h3>
409
+ <p class="text-xs text-gray-500 mb-4 max-w-xs mx-auto">
410
+ Đặt biến môi trường <code class="text-brand-400">ADMIN_API_URL</code> để kết nối với Admin Worker và sử dụng tính năng backup.
411
+ </p>
412
+ <div class="bg-gray-800 rounded-lg p-3 text-left text-xs font-mono text-gray-400 max-w-sm mx-auto space-y-1">
413
+ <div>ADMIN_API_URL=https://your-worker.workers.dev</div>
414
+ </div>
415
+ </div>
416
+
417
+ <!-- Configured: Status & Actions -->
418
+ <div x-show="backupStatus.configured" class="space-y-4">
419
+
420
+ <!-- Status Card -->
421
+ <div class="bg-gray-900 rounded-xl border border-gray-800 p-4">
422
+ <div class="flex items-center gap-3 mb-3">
423
+ <div class="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
424
+ <i data-lucide="cloud" class="w-5 h-5 text-brand-400"></i>
425
+ </div>
426
+ <div class="flex-1 min-w-0">
427
+ <div class="text-sm font-medium">Cloud Backup</div>
428
+ <div class="text-xs text-gray-500 font-mono truncate" x-text="backupStatus.admin_url"></div>
429
+ </div>
430
+ <div x-show="backupStatus.running" class="flex items-center gap-2">
431
+ <div class="w-4 h-4 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
432
+ <span class="text-xs text-brand-400">Đang chạy</span>
433
+ </div>
434
+ </div>
435
+
436
+ <!-- Progress -->
437
+ <div x-show="backupStatus.progress" class="text-xs text-gray-400 mb-3 px-1" x-text="backupStatus.progress"></div>
438
+
439
+ <!-- Error -->
440
+ <div x-show="backupStatus.error" class="text-xs text-red-400 bg-red-400/10 rounded-lg px-3 py-2 mb-3" x-text="backupStatus.error"></div>
441
+
442
+ <!-- Last backup -->
443
+ <div x-show="backupStatus.last" class="text-xs text-gray-500 px-1">
444
+ Lần cuối: <span x-text="backupStatus.last ? new Date(backupStatus.last).toLocaleString('vi-VN') : 'Chưa có'" class="text-gray-400"></span>
445
+ </div>
446
+ </div>
447
+
448
+ <!-- Action Buttons -->
449
+ <div class="grid grid-cols-2 gap-2">
450
+ <button @click="backupZone(currentZone)" :disabled="backupStatus.running"
451
+ :class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-brand-500'"
452
+ class="flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 rounded-xl text-sm font-medium transition">
453
+ <i data-lucide="upload-cloud" class="w-4 h-4"></i>
454
+ Backup Zone này
455
+ </button>
456
+ <button @click="backupAll()" :disabled="backupStatus.running"
457
+ :class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-brand-500'"
458
+ class="flex items-center justify-center gap-2 px-4 py-3 bg-brand-600 rounded-xl text-sm font-medium transition">
459
+ <i data-lucide="cloud-upload" class="w-4 h-4"></i>
460
+ Backup tất cả
461
+ </button>
462
+ <button @click="restoreZone(currentZone)" :disabled="backupStatus.running"
463
+ :class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
464
+ class="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
465
+ <i data-lucide="download-cloud" class="w-4 h-4"></i>
466
+ Restore Zone này
467
+ </button>
468
+ <button @click="restoreAll()" :disabled="backupStatus.running"
469
+ :class="backupStatus.running ? 'opacity-50 cursor-not-allowed' : 'hover:bg-emerald-500'"
470
+ class="flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 rounded-xl text-sm font-medium transition">
471
+ <i data-lucide="cloud-download" class="w-4 h-4"></i>
472
+ Restore tất cả
473
+ </button>
474
+ </div>
475
+
476
+ <!-- Backup List -->
477
+ <div class="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
478
+ <div class="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
479
+ <h3 class="text-sm font-medium text-gray-300">Bản backup trên cloud</h3>
480
+ <button @click="loadBackupList()" class="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-800 transition" title="Refresh">
481
+ <i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
482
+ </button>
483
+ </div>
484
+
485
+ <div x-show="backupLoading" class="flex items-center justify-center py-8">
486
+ <div class="w-5 h-5 border-2 border-brand-500 border-t-transparent rounded-full animate-spin"></div>
487
+ </div>
488
+
489
+ <div x-show="!backupLoading && backupList.length === 0" class="text-center py-8 text-gray-600 text-sm">
490
+ Chưa có bản backup nào
491
+ </div>
492
+
493
+ <div x-show="!backupLoading" class="divide-y divide-gray-800/50">
494
+ <template x-for="b in backupList" :key="b.zone_name">
495
+ <div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition">
496
+ <div class="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
497
+ <i data-lucide="archive" class="w-4 h-4 text-brand-400"></i>
498
+ </div>
499
+ <div class="flex-1 min-w-0">
500
+ <div class="text-sm font-medium truncate" x-text="b.zone_name"></div>
501
+ <div class="text-xs text-gray-500">
502
+ <span x-show="b.size" x-text="(b.size / 1024 / 1024).toFixed(1) + ' MB'"></span>
503
+ <span x-show="b.last_modified"> · <span x-text="b.last_modified"></span></span>
504
+ </div>
505
+ </div>
506
+ <button @click="restoreZone(b.zone_name)" :disabled="backupStatus.running"
507
+ class="p-2 rounded-lg text-gray-400 hover:text-emerald-400 hover:bg-emerald-400/10 transition" title="Restore">
508
+ <i data-lucide="download-cloud" class="w-4 h-4"></i>
509
+ </button>
510
+ </div>
511
+ </template>
512
+ </div>
513
+ </div>
514
+ </div>
515
+ </div>
516
+ </div>
517
  </div>
518
  </main>
519
  </div>
520
 
521
+ </div><!-- end x-show="user" -->
522
+
523
  <!-- ═══ MODAL: Create Zone ═══ -->
524
  <div x-show="showCreateZone" x-transition:enter="transition duration-200" x-transition:leave="transition duration-150"
525
  class="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">