sjfs commited on
Commit
7f4dc1a
·
verified ·
1 Parent(s): e10a1ce

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +518 -241
app.py CHANGED
@@ -3,327 +3,573 @@ import tarfile
3
  import datetime
4
  import threading
5
  import time
 
 
6
  from flask import Flask, render_template_string, request, jsonify
7
  from huggingface_hub import HfApi
8
 
9
  app = Flask(__name__)
10
 
11
- # 配置
12
  SOURCE_MOUNT = "/mnt/data"
13
  BACKUP_DATASET = "sjfs/memos-backup-data"
 
 
 
 
 
 
14
 
15
- # 自动备份配置(持久化存储)
16
- CONFIG_FILE = "/tmp/auto_backup_config.json" # 实际项目中可改为持久化路径
17
- auto_interval_seconds = 86400 # 默认 24 小时
18
- auto_backup_running = True
19
 
20
  def load_config():
21
- """加载自动备份配置"""
22
  global auto_interval_seconds
23
  try:
24
  with open(CONFIG_FILE, 'r') as f:
25
- import json
26
  config = json.load(f)
27
  auto_interval_seconds = config.get('interval_seconds', 86400)
28
- except:
29
  pass
30
 
 
31
  def save_config():
32
- """保存自动备份配置"""
33
- global auto_interval_seconds
34
  try:
35
- import json
36
  with open(CONFIG_FILE, 'w') as f:
37
  json.dump({'interval_seconds': auto_interval_seconds}, f)
38
- except:
39
  pass
40
 
41
- # HTML 模板(包含自动备份设置)
42
  HTML_TEMPLATE = """
43
  <!DOCTYPE html>
44
- <html>
45
  <head>
46
- <title>Memos 备份管理</title>
47
  <meta charset="utf-8">
 
 
48
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  body {
50
- font-family: system-ui, sans-serif;
51
- max-width: 800px;
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  margin: 0 auto;
53
- padding: 20px;
54
- background: #f5f5f5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  .card {
57
- background: white;
58
- border-radius: 12px;
 
59
  padding: 24px;
60
- margin-bottom: 20px;
61
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
62
- }
63
- h1 { margin-top: 0; color: #333; }
64
- h2 { font-size: 18px; margin-bottom: 16px; color: #555; }
65
- button {
66
- background: #0066cc;
67
- color: white;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  border: none;
69
- padding: 10px 20px;
70
- border-radius: 6px;
 
 
71
  cursor: pointer;
72
- font-size: 14px;
73
- margin-right: 10px;
74
- }
75
- button:hover { background: #0052a3; }
76
- .danger-btn { background: #dc3545; }
77
- .danger-btn:hover { background: #bb2d3b; }
78
- .success-btn { background: #28a745; }
79
- .success-btn:hover { background: #1e7e34; }
80
- .backup-list {
81
- list-style: none;
82
- padding: 0;
83
- }
84
- .backup-list li {
85
- background: #f8f9fa;
86
- margin: 8px 0;
87
- padding: 12px;
88
- border-radius: 6px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  display: flex;
90
  justify-content: space-between;
91
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  }
93
- .status-success { background: #d4edda; color: #155724; padding: 8px 12px; border-radius: 6px; }
94
- .status-error { background: #f8d7da; color: #721c24; padding: 8px 12px; border-radius: 6px; }
95
- .status-info { background: #d1ecf1; color: #0c5460; padding: 8px 12px; border-radius: 6px; }
96
- .info-text { margin-top: 12px; }
97
- select, input {
98
- padding: 8px;
99
- border-radius: 6px;
100
- border: 1px solid #ccc;
101
- margin-right: 10px;
102
  }
 
103
  .auto-config {
104
- background: #e9ecef;
105
- padding: 15px;
106
- border-radius: 8px;
107
- margin-top: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  }
109
- hr { margin: 20px 0; }
110
  </style>
111
  </head>
112
  <body>
113
- <div class="card">
114
- <h1>📦 Memos 备份管理器</h1>
115
- <p>源数据路径:<code>{{ source_path }}</code></p>
116
- <p>备份目标:<code>{{ backup_dataset }}</code></p>
117
- </div>
 
 
 
118
 
119
- <div class="card">
120
- <h2>🔄 手动备份</h2>
121
- <button id="backupBtn" onclick="manualBackup()">立即备份</button>
122
- <div id="backupResult" class="info-text"></div>
123
- </div>
124
 
125
- <div class="card">
126
- <h2>⏰ 自动备份设置</h2>
127
- <div class="auto-config">
128
- <label>备份间隔</label>
129
- <input type="number" id="intervalInput" value="{{ auto_interval_minutes }}" min="1" style="width:100px;">
130
- <select id="intervalUnit">
131
- <option value="minute">分钟</option>
132
- <option value="hour">小时</option>
133
- <option value="day">天</option>
134
- </select>
135
- <button class="success-btn" onclick="setAutoInterval()">保存设置</button>
136
- <div id="intervalResult" class="info-text"></div>
137
- <div id="autoStatus" class="info-text" style="margin-top:10px;">加载中...</div>
138
- </div>
139
- </div>
140
 
141
- <div class="card">
142
- <h2>📋 已有备份</h2>
143
- <div id="backupList">加载中...</div>
144
- </div>
 
 
145
 
146
- <div class="card">
147
- <h2>⚠️ 恢复操作</h2>
148
- <p class="info-text" style="color: #dc3545;">从备份恢复覆盖当前数据,请谨慎操作</p>
149
- <select id="restoreSelect">
150
- <option value="">-- 选择要恢复的备份 --</option>
151
- </select>
152
- <button id="restoreBtn" class="danger-btn" onclick="manualRestore()">执行恢复</button>
153
- <div id="restoreResult" class="info-text"></div>
 
 
 
154
  </div>
155
 
156
  <script>
157
- let currentIntervalSeconds = {{ auto_interval_seconds }};
158
-
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  function formatInterval(seconds) {
160
- if (seconds < 3600) return (seconds / 60) + ' 分钟';
161
- if (seconds < 86400) return (seconds / 3600) + ' 小时';
162
- return (seconds / 86400) + ' ';
163
  }
164
-
 
 
 
 
 
 
 
165
  async function loadAutoStatus() {
166
- const response = await fetch('/api/auto_status');
167
- const data = await response.json();
168
- const statusDiv = document.getElementById('autoStatus');
169
- if (data.enabled) {
170
- statusDiv.innerHTML = '🤖 自动备份已启用 · 间隔:' + formatInterval(data.interval_seconds) +
171
- '<br>📅 下次备份:' + data.next_backup;
172
- } else {
173
- statusDiv.innerHTML = '⏸️ 自动备份未启动';
 
 
 
 
 
174
  }
175
  }
176
-
177
  async function setAutoInterval() {
178
- const value = document.getElementById('intervalInput').value;
179
- const unit = document.getElementById('intervalUnit').value;
180
- let seconds = parseInt(value);
181
-
182
  if (isNaN(seconds) || seconds < 1) {
183
  alert('请输入有效的数字');
184
  return;
185
  }
186
-
187
- if (unit === 'hour') seconds = seconds * 3600;
188
- if (unit === 'day') seconds = seconds * 86400;
189
  if (seconds < 60) {
190
  alert('间隔不能小于 1 分钟');
191
  return;
192
  }
193
-
194
- const response = await fetch('/api/set_interval', {
195
  method: 'POST',
196
  headers: { 'Content-Type': 'application/json' },
197
  body: JSON.stringify({ interval_seconds: seconds })
198
  });
199
- const data = await response.json();
200
-
 
201
  if (data.success) {
202
- document.getElementById('intervalResult').innerHTML =
203
- '<div class="status-success">✅ ' + data.message + '</div>';
204
- currentIntervalSeconds = seconds;
205
  loadAutoStatus();
206
- setTimeout(() => {
207
- document.getElementById('intervalResult').innerHTML = '';
208
- }, 3000);
209
  } else {
210
- document.getElementById('intervalResult').innerHTML =
211
- '<div class="status-error">❌ ' + data.error + '</div>';
212
  }
213
  }
214
-
215
  async function refreshBackupList() {
216
- const response = await fetch('/api/backups');
217
- const backups = await response.json();
218
- const listDiv = document.getElementById('backupList');
219
- const select = document.getElementById('restoreSelect');
220
-
221
- if (backups.length === 0) {
222
- listDiv.innerHTML = '<div class="status-info">暂无备份</div>';
223
- select.innerHTML = '<option value="">-- 暂无备份 --</option>';
224
- return;
225
- }
226
-
227
- let html = '<ul class="backup-list">';
228
- select.innerHTML = '<option value="">-- 选择要恢复的备份 --</option>';
229
- for (const b of backups) {
230
- const size = (b.size / 1024 / 1024).toFixed(2);
231
- html += `<li><span>📄 ${b.name}</span><span>${size} MB | ${b.date}</span></li>`;
232
- select.innerHTML += `<option value="${b.name}">${b.name} (${size} MB)</option>`;
 
 
 
 
 
 
 
 
 
 
 
 
233
  }
234
- html += '</ul>';
235
- listDiv.innerHTML = html;
236
  }
237
-
238
  async function manualBackup() {
239
- const btn = document.getElementById('backupBtn');
240
- const resultDiv = document.getElementById('backupResult');
241
  btn.disabled = true;
242
  btn.textContent = '备份中...';
243
- resultDiv.innerHTML = '<div class="status-info">正在备份,请稍候...</div>';
244
-
245
- const response = await fetch('/api/backup', { method: 'POST' });
246
- const data = await response.json();
247
-
248
- if (data.success) {
249
- resultDiv.innerHTML = '<div class="status-success">备份成功: ' + (data.backup_name || '已完成') + '</div>';
250
- refreshBackupList();
251
- } else {
252
- resultDiv.innerHTML = '<div class="status-error">❌ ' + data.error + '</div>';
 
 
 
 
253
  }
 
254
  btn.disabled = false;
255
- btn.textContent = '立即备份';
256
  }
257
-
258
  async function manualRestore() {
259
- const select = document.getElementById('restoreSelect');
260
- const backupName = select.value;
261
  if (!backupName) {
262
  alert('请先选择一个备份');
263
  return;
264
  }
265
-
266
- if (!confirm('⚠️ 警告:恢复操作会覆盖当前数据,确定要继续吗?')) return;
267
-
268
- const resultDiv = document.getElementById('restoreResult');
269
- resultDiv.innerHTML = '<div class="status-info">正在恢复,请稍候...</div>';
270
-
271
- const response = await fetch('/api/restore', {
272
- method: 'POST',
273
- headers: { 'Content-Type': 'application/json' },
274
- body: JSON.stringify({ backup_name: backupName })
275
- });
276
- const data = await response.json();
277
-
278
- if (data.success) {
279
- resultDiv.innerHTML = '<div class="status-success">✅ ' + data.message + '</div>';
280
- refreshBackupList();
281
- } else {
282
- resultDiv.innerHTML = '<div class="status-error">❌ ' + data.error + '</div>';
 
283
  }
284
  }
285
-
286
- // 页面加载
287
  refreshBackupList();
288
- setInterval(refreshBackupList, 30000);
289
  loadAutoStatus();
 
290
  setInterval(loadAutoStatus, 60000);
291
  </script>
292
  </body>
293
  </html>
294
  """
295
 
 
296
  def get_backup_files():
297
- """获取备份列表"""
298
  try:
299
  api = HfApi()
300
- files = api.list_repo_files(repo_id=BACKUP_DATASET, repo_type="dataset")
301
- backups = [f for f in files if f.startswith("backup_") and f.endswith(".tar.gz")]
302
- backups.sort(reverse=True)
303
- result = []
304
- for b in backups:
305
- result.append({
306
- "name": b,
307
- "date": b.replace("backup_", "").replace(".tar.gz", ""),
308
- "size": 0
309
- })
310
- return result
 
311
  except Exception as e:
312
  print(f"获取备份列表失败: {e}")
313
  return []
314
 
 
315
  def create_backup():
316
- """创建备份"""
317
  timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
318
  backup_name = f"backup_{timestamp}.tar.gz"
319
  backup_path = f"/tmp/{backup_name}"
320
-
321
  try:
322
- # 打包源数据
323
  with tarfile.open(backup_path, "w:gz") as tar:
324
  tar.add(SOURCE_MOUNT, arcname="memos_data")
325
-
326
- # 上传到 Dataset
327
  api = HfApi()
328
  api.upload_file(
329
  path_or_fileobj=backup_path,
@@ -331,31 +577,56 @@ def create_backup():
331
  repo_id=BACKUP_DATASET,
332
  repo_type="dataset"
333
  )
334
-
335
  os.remove(backup_path)
 
336
  return {"success": True, "backup_name": backup_name}
337
  except Exception as e:
338
  return {"success": False, "error": str(e)}
339
 
 
 
 
 
 
 
 
 
 
 
 
340
  @app.route('/')
341
  def index():
342
- auto_interval_minutes = auto_interval_seconds // 60
 
 
 
 
 
 
 
 
 
 
343
  return render_template_string(
344
  HTML_TEMPLATE,
345
  source_path=SOURCE_MOUNT,
346
  backup_dataset=BACKUP_DATASET,
347
- auto_interval_seconds=auto_interval_seconds,
348
- auto_interval_minutes=auto_interval_minutes
 
349
  )
350
 
 
351
  @app.route('/api/backups')
352
  def list_backups():
353
  return jsonify(get_backup_files())
354
 
 
355
  @app.route('/api/backup', methods=['POST'])
356
  def do_backup():
357
- result = create_backup()
358
- return jsonify(result)
359
 
360
  @app.route('/api/restore', methods=['POST'])
361
  def do_restore():
@@ -363,7 +634,7 @@ def do_restore():
363
  backup_name = data.get('backup_name')
364
  if not backup_name:
365
  return jsonify({"success": False, "error": "未指定备份文件"})
366
-
367
  try:
368
  api = HfApi()
369
  backup_path = f"/tmp/{backup_name}"
@@ -373,11 +644,10 @@ def do_restore():
373
  local_dir="/tmp",
374
  repo_type="dataset"
375
  )
376
-
377
  with tarfile.open(backup_path, "r:gz") as tar:
378
- tar.extractall("/tmp/restore")
379
-
380
- import shutil
381
  for item in os.listdir("/tmp/restore/memos_data"):
382
  src = f"/tmp/restore/memos_data/{item}"
383
  dst = f"{SOURCE_MOUNT}/{item}"
@@ -387,60 +657,67 @@ def do_restore():
387
  shutil.copytree(src, dst)
388
  else:
389
  shutil.copy2(src, dst)
390
-
391
  os.remove(backup_path)
392
- shutil.rmtree("/tmp/restore")
393
-
394
  return jsonify({"success": True, "message": f"已从 {backup_name} 恢复"})
395
  except Exception as e:
396
  return jsonify({"success": False, "error": str(e)})
397
 
 
398
  @app.route('/api/set_interval', methods=['POST'])
399
  def set_interval():
400
  global auto_interval_seconds
401
  data = request.json
402
  new_interval = data.get('interval_seconds')
403
-
404
  if not new_interval or new_interval < 60:
405
  return jsonify({"success": False, "error": "间隔不能小于 60 秒"})
406
-
407
  auto_interval_seconds = new_interval
408
  save_config()
409
-
410
- # 重启自动备份线程
411
- global auto_backup_running
412
- auto_backup_running = False
413
- time.sleep(1)
414
- auto_backup_running = True
415
- start_auto_backup_thread()
416
-
417
  return jsonify({"success": True, "message": f"自动备份间隔已设为 {new_interval // 60} 分钟"})
418
 
 
419
  @app.route('/api/auto_status')
420
  def auto_status():
421
  next_time = datetime.datetime.now() + datetime.timedelta(seconds=auto_interval_seconds)
422
  return jsonify({
423
  "enabled": True,
424
  "interval_seconds": auto_interval_seconds,
425
- "next_backup": next_time.strftime("%Y-%m-%d %H:%M:%S")
 
426
  })
427
 
 
428
  def auto_backup_worker():
429
- """后台自动备份线程"""
430
- global auto_interval_seconds, auto_backup_running
431
- while auto_backup_running:
432
- time.sleep(auto_interval_seconds)
433
  with app.app_context():
434
  result = create_backup()
435
  print(f"[{datetime.datetime.now()}] 自动备份: {result}")
436
 
 
437
  def start_auto_backup_thread():
438
- thread = threading.Thread(target=auto_backup_worker, daemon=True)
439
- thread.start()
 
 
 
 
 
 
 
 
 
 
440
 
441
- # 启动时加载配置并启动自动备份
442
  load_config()
443
  start_auto_backup_thread()
444
 
445
  if __name__ == '__main__':
446
- app.run(host='0.0.0.0', port=7860)
 
3
  import datetime
4
  import threading
5
  import time
6
+ import json
7
+ import shutil
8
  from flask import Flask, render_template_string, request, jsonify
9
  from huggingface_hub import HfApi
10
 
11
  app = Flask(__name__)
12
 
 
13
  SOURCE_MOUNT = "/mnt/data"
14
  BACKUP_DATASET = "sjfs/memos-backup-data"
15
+ CONFIG_FILE = "/tmp/auto_backup_config.json"
16
+
17
+ auto_interval_seconds = 86400
18
+ _last_backup_time = None
19
+ _auto_stop_event = threading.Event()
20
+ _auto_thread = None
21
 
 
 
 
 
22
 
23
  def load_config():
 
24
  global auto_interval_seconds
25
  try:
26
  with open(CONFIG_FILE, 'r') as f:
 
27
  config = json.load(f)
28
  auto_interval_seconds = config.get('interval_seconds', 86400)
29
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
30
  pass
31
 
32
+
33
  def save_config():
 
 
34
  try:
 
35
  with open(CONFIG_FILE, 'w') as f:
36
  json.dump({'interval_seconds': auto_interval_seconds}, f)
37
+ except OSError:
38
  pass
39
 
40
+
41
  HTML_TEMPLATE = """
42
  <!DOCTYPE html>
43
+ <html lang="zh-CN">
44
  <head>
 
45
  <meta charset="utf-8">
46
+ <meta name="viewport" content="width=device-width, initial-scale=1">
47
+ <title>Memos Backup</title>
48
  <style>
49
+ :root {
50
+ --bg: #F5F3EF;
51
+ --surface: #FFFFFF;
52
+ --border: #DDD9D2;
53
+ --border-light: #EBE8E2;
54
+ --text: #1F1F1F;
55
+ --text-muted: #8A8A8A;
56
+ --text-faint: #B5B5B5;
57
+ --accent: #6B7280;
58
+ --accent-hover: #555D67;
59
+ --accent-surface: #F2F3F5;
60
+ --green: #6B8F71;
61
+ --green-surface: #EFF5F0;
62
+ --red: #B86B6B;
63
+ --red-surface: #F5F0F0;
64
+ --blue: #6B8BA4;
65
+ --blue-surface: #EFF4F7;
66
+ --radius: 5px;
67
+ --transition: 0.2s ease;
68
+ }
69
+
70
+ * { margin: 0; padding: 0; box-sizing: border-box; }
71
+
72
  body {
73
+ font-family: -apple-system, 'Hiragino Sans', 'Noto Sans SC', 'Yu Gothic', sans-serif;
74
+ background: var(--bg);
75
+ color: var(--text);
76
+ font-size: 14px;
77
+ line-height: 1.6;
78
+ -webkit-font-smoothing: antialiased;
79
+ }
80
+
81
+ .top-bar {
82
+ height: 3px;
83
+ background: var(--accent);
84
+ }
85
+
86
+ .container {
87
+ max-width: 680px;
88
  margin: 0 auto;
89
+ padding: 40px 20px 60px;
90
+ }
91
+
92
+ .header {
93
+ margin-bottom: 36px;
94
+ }
95
+
96
+ .header h1 {
97
+ font-size: 22px;
98
+ font-weight: 600;
99
+ letter-spacing: -0.02em;
100
+ color: var(--text);
101
+ margin-bottom: 8px;
102
+ }
103
+
104
+ .header h1::after {
105
+ content: '';
106
+ display: block;
107
+ width: 24px;
108
+ height: 2px;
109
+ background: var(--accent);
110
+ margin-top: 10px;
111
  }
112
+
113
+ .header-meta {
114
+ font-size: 12px;
115
+ color: var(--text-muted);
116
+ margin-top: 10px;
117
+ }
118
+
119
+ .header-meta code {
120
+ background: var(--accent-surface);
121
+ padding: 2px 6px;
122
+ border-radius: 3px;
123
+ font-size: 11px;
124
+ color: var(--accent);
125
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
126
+ }
127
+
128
+ .header-meta .sep {
129
+ margin: 0 6px;
130
+ color: var(--text-faint);
131
+ }
132
+
133
  .card {
134
+ background: var(--surface);
135
+ border: 1px solid var(--border);
136
+ border-radius: var(--radius);
137
  padding: 24px;
138
+ margin-bottom: 16px;
139
+ transition: border-color var(--transition);
140
+ }
141
+
142
+ .card:hover {
143
+ border-color: #CCC8C0;
144
+ }
145
+
146
+ .card-title {
147
+ font-size: 11px;
148
+ font-weight: 600;
149
+ text-transform: uppercase;
150
+ letter-spacing: 0.1em;
151
+ color: var(--text-muted);
152
+ margin-bottom: 16px;
153
+ }
154
+
155
+ .btn {
156
+ display: inline-flex;
157
+ align-items: center;
158
+ justify-content: center;
159
+ padding: 9px 20px;
160
  border: none;
161
+ border-radius: var(--radius);
162
+ font-size: 13px;
163
+ font-weight: 500;
164
+ letter-spacing: 0.02em;
165
  cursor: pointer;
166
+ transition: background var(--transition), opacity var(--transition);
167
+ background: var(--accent);
168
+ color: #fff;
169
+ font-family: inherit;
170
+ }
171
+
172
+ .btn:hover { background: var(--accent-hover); }
173
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
174
+
175
+ .btn-danger { background: var(--red); }
176
+ .btn-danger:hover { background: #A55D5D; }
177
+
178
+ input, select {
179
+ padding: 8px 12px;
180
+ border: 1px solid var(--border);
181
+ border-radius: var(--radius);
182
+ font-size: 13px;
183
+ color: var(--text);
184
+ background: var(--surface);
185
+ transition: border-color var(--transition);
186
+ font-family: inherit;
187
+ }
188
+
189
+ input:focus, select:focus {
190
+ outline: none;
191
+ border-color: var(--accent);
192
+ }
193
+
194
+ .status {
195
+ margin-top: 12px;
196
+ padding: 10px 14px;
197
+ border-radius: var(--radius);
198
+ font-size: 13px;
199
+ line-height: 1.5;
200
+ animation: fadeIn 0.3s ease;
201
+ }
202
+
203
+ .status-success { background: var(--green-surface); color: var(--green); }
204
+ .status-error { background: var(--red-surface); color: var(--red); }
205
+ .status-info { background: var(--blue-surface); color: var(--blue); }
206
+
207
+ @keyframes fadeIn {
208
+ from { opacity: 0; transform: translateY(-4px); }
209
+ to { opacity: 1; transform: translateY(0); }
210
+ }
211
+
212
+ .backup-list { list-style: none; }
213
+
214
+ .backup-item {
215
  display: flex;
216
  justify-content: space-between;
217
  align-items: center;
218
+ padding: 10px 0;
219
+ border-bottom: 1px solid var(--border-light);
220
+ font-size: 13px;
221
+ }
222
+
223
+ .backup-item:last-child { border-bottom: none; }
224
+
225
+ .backup-date {
226
+ color: var(--text);
227
+ font-size: 13px;
228
+ font-weight: 500;
229
+ }
230
+
231
+ .backup-filename {
232
+ color: var(--text-faint);
233
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
234
+ font-size: 11px;
235
+ margin-top: 2px;
236
+ }
237
+
238
+ .backup-size {
239
+ color: var(--text-muted);
240
+ font-size: 12px;
241
+ white-space: nowrap;
242
  }
243
+
244
+ .empty-state {
245
+ text-align: center;
246
+ padding: 24px;
247
+ color: var(--text-faint);
248
+ font-size: 13px;
 
 
 
249
  }
250
+
251
  .auto-config {
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 8px;
255
+ flex-wrap: wrap;
256
+ }
257
+
258
+ .auto-config label {
259
+ font-size: 13px;
260
+ color: var(--text-muted);
261
+ }
262
+
263
+ .auto-status {
264
+ margin-top: 14px;
265
+ padding-top: 14px;
266
+ border-top: 1px solid var(--border-light);
267
+ font-size: 12px;
268
+ color: var(--text-muted);
269
+ line-height: 1.8;
270
+ }
271
+
272
+ .pulse {
273
+ display: inline-block;
274
+ width: 6px;
275
+ height: 6px;
276
+ border-radius: 50%;
277
+ background: var(--green);
278
+ margin-right: 6px;
279
+ vertical-align: middle;
280
+ animation: pulse 2s ease-in-out infinite;
281
+ }
282
+
283
+ @keyframes pulse {
284
+ 0%, 100% { opacity: 1; }
285
+ 50% { opacity: 0.3; }
286
+ }
287
+
288
+ .warning-block {
289
+ font-size: 12px;
290
+ color: var(--red);
291
+ margin-bottom: 14px;
292
+ padding: 10px 14px;
293
+ background: var(--red-surface);
294
+ border-radius: var(--radius);
295
+ border-left: 3px solid var(--red);
296
+ line-height: 1.5;
297
+ }
298
+
299
+ .restore-row {
300
+ display: flex;
301
+ align-items: center;
302
+ gap: 8px;
303
+ }
304
+
305
+ @media (max-width: 480px) {
306
+ .container { padding: 24px 16px 40px; }
307
+ .card { padding: 18px; }
308
+ .auto-config { flex-direction: column; align-items: stretch; }
309
+ .restore-row { flex-direction: column; align-items: stretch; }
310
+ .restore-row .btn { width: 100%; }
311
  }
 
312
  </style>
313
  </head>
314
  <body>
315
+ <div class="top-bar"></div>
316
+ <div class="container">
317
+ <header class="header">
318
+ <h1>Memos Backup</h1>
319
+ <div class="header-meta">
320
+ 源 <code>{{ source_path }}</code><span class="sep">→</span>目标 <code>{{ backup_dataset }}</code>
321
+ </div>
322
+ </header>
323
 
324
+ <section class="card">
325
+ <div class="card-title">手动备份</div>
326
+ <button class="btn" id="backupBtn" onclick="manualBackup()">执行备份</button>
327
+ <div id="backupResult"></div>
328
+ </section>
329
 
330
+ <section class="card">
331
+ <div class="card-title">自动备份</div>
332
+ <div class="auto-config">
333
+ <label>间隔</label>
334
+ <input type="number" id="intervalInput" value="{{ auto_interval_value }}" min="1" style="width:80px;">
335
+ <select id="intervalUnit">
336
+ <option value="minute" {% if auto_interval_unit == 'minute' %}selected{% endif %}>分钟</option>
337
+ <option value="hour" {% if auto_interval_unit == 'hour' %}selected{% endif %}>小时</option>
338
+ <option value="day" {% if auto_interval_unit == 'day' %}selected{% endif %}>天</option>
339
+ </select>
340
+ <button class="btn" onclick="setAutoInterval()">保存</button>
341
+ </div>
342
+ <div id="intervalResult"></div>
343
+ <div class="auto-status" id="autoStatus">加载中...</div>
344
+ </section>
345
 
346
+ <section class="card">
347
+ <div class="card-title">备份记录</div>
348
+ <div id="backupList">
349
+ <div class="empty-state">加载中...</div>
350
+ </div>
351
+ </section>
352
 
353
+ <section class="card">
354
+ <div class="card-title">恢复数据</div>
355
+ <div class="warning-block">恢复操作将覆盖当前数据,操作不可逆。</div>
356
+ <div class="restore-row">
357
+ <select id="restoreSelect" style="flex:1;">
358
+ <option value="">选择备份文件</option>
359
+ </select>
360
+ <button class="btn btn-danger" onclick="manualRestore()">恢复</button>
361
+ </div>
362
+ <div id="restoreResult"></div>
363
+ </section>
364
  </div>
365
 
366
  <script>
367
+ function escapeHtml(str) {
368
+ var div = document.createElement('div');
369
+ div.textContent = str;
370
+ return div.innerHTML;
371
+ }
372
+
373
+ function formatDate(dateStr) {
374
+ var parts = dateStr.split('_');
375
+ if (parts.length !== 2) return dateStr;
376
+ var d = parts[0], t = parts[1];
377
+ if (d.length !== 8 || t.length !== 6) return dateStr;
378
+ return d.substr(0,4) + '-' + d.substr(4,2) + '-' + d.substr(6,2) + ' ' +
379
+ t.substr(0,2) + ':' + t.substr(2,2) + ':' + t.substr(4,2);
380
+ }
381
+
382
  function formatInterval(seconds) {
383
+ if (seconds >= 86400 && seconds % 86400 === 0) return (seconds / 86400) + ' ';
384
+ if (seconds >= 3600 && seconds % 3600 === 0) return (seconds / 3600) + ' 小时';
385
+ return Math.round(seconds / 60) + ' 分钟';
386
  }
387
+
388
+ function formatSize(bytes) {
389
+ if (!bytes) return '--';
390
+ if (bytes < 1024) return bytes + ' B';
391
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
392
+ return (bytes / 1024 / 1024).toFixed(2) + ' MB';
393
+ }
394
+
395
  async function loadAutoStatus() {
396
+ try {
397
+ var response = await fetch('/api/auto_status');
398
+ var data = await response.json();
399
+ var el = document.getElementById('autoStatus');
400
+ if (data.enabled) {
401
+ el.innerHTML = '<span class="pulse"></span>已启用 · 间隔 ' + formatInterval(data.interval_seconds) +
402
+ '<br>下次备份 ' + escapeHtml(data.next_backup) +
403
+ (data.last_backup ? '<br>上次备份 ' + escapeHtml(data.last_backup) : '');
404
+ } else {
405
+ el.innerHTML = '未启用';
406
+ }
407
+ } catch (e) {
408
+ document.getElementById('autoStatus').innerHTML = '状态获取失败';
409
  }
410
  }
411
+
412
  async function setAutoInterval() {
413
+ var value = document.getElementById('intervalInput').value;
414
+ var unit = document.getElementById('intervalUnit').value;
415
+ var seconds = parseInt(value);
416
+
417
  if (isNaN(seconds) || seconds < 1) {
418
  alert('请输入有效的数字');
419
  return;
420
  }
421
+
422
+ if (unit === 'hour') seconds *= 3600;
423
+ if (unit === 'day') seconds *= 86400;
424
  if (seconds < 60) {
425
  alert('间隔不能小于 1 分钟');
426
  return;
427
  }
428
+
429
+ var response = await fetch('/api/set_interval', {
430
  method: 'POST',
431
  headers: { 'Content-Type': 'application/json' },
432
  body: JSON.stringify({ interval_seconds: seconds })
433
  });
434
+ var data = await response.json();
435
+ var el = document.getElementById('intervalResult');
436
+
437
  if (data.success) {
438
+ el.innerHTML = '<div class="status status-success">设置已保存</div>';
 
 
439
  loadAutoStatus();
440
+ setTimeout(function() { el.innerHTML = ''; }, 3000);
 
 
441
  } else {
442
+ el.innerHTML = '<div class="status status-error">' + escapeHtml(data.error) + '</div>';
 
443
  }
444
  }
445
+
446
  async function refreshBackupList() {
447
+ try {
448
+ var response = await fetch('/api/backups');
449
+ var backups = await response.json();
450
+ var listDiv = document.getElementById('backupList');
451
+ var select = document.getElementById('restoreSelect');
452
+
453
+ if (backups.length === 0) {
454
+ listDiv.innerHTML = '<div class="empty-state">暂无备份记录</div>';
455
+ select.innerHTML = '<option value="">暂无备份</option>';
456
+ return;
457
+ }
458
+
459
+ var html = '<ul class="backup-list">';
460
+ select.innerHTML = '<option value="">选择备份文件</option>';
461
+ for (var i = 0; i < backups.length; i++) {
462
+ var b = backups[i];
463
+ var dateFormatted = formatDate(b.date);
464
+ var sizeFormatted = formatSize(b.size);
465
+ html += '<li class="backup-item">' +
466
+ '<div><div class="backup-date">' + escapeHtml(dateFormatted) + '</div>' +
467
+ '<div class="backup-filename">' + escapeHtml(b.name) + '</div></div>' +
468
+ '<div class="backup-size">' + sizeFormatted + '</div></li>';
469
+ select.innerHTML += '<option value="' + escapeHtml(b.name) + '">' +
470
+ escapeHtml(dateFormatted) + ' (' + sizeFormatted + ')</option>';
471
+ }
472
+ html += '</ul>';
473
+ listDiv.innerHTML = html;
474
+ } catch (e) {
475
+ document.getElementById('backupList').innerHTML = '<div class="empty-state">加载失败</div>';
476
  }
 
 
477
  }
478
+
479
  async function manualBackup() {
480
+ var btn = document.getElementById('backupBtn');
481
+ var resultDiv = document.getElementById('backupResult');
482
  btn.disabled = true;
483
  btn.textContent = '备份中...';
484
+ resultDiv.innerHTML = '<div class="status status-info">正在备份,请稍候...</div>';
485
+
486
+ try {
487
+ var response = await fetch('/api/backup', { method: 'POST' });
488
+ var data = await response.json();
489
+ if (data.success) {
490
+ resultDiv.innerHTML = '<div class="status status-success">备份· ' + escapeHtml(data.backup_name || '') + '</div>';
491
+ refreshBackupList();
492
+ loadAutoStatus();
493
+ } else {
494
+ resultDiv.innerHTML = '<div class="status status-error">' + escapeHtml(data.error) + '</div>';
495
+ }
496
+ } catch (e) {
497
+ resultDiv.innerHTML = '<div class="status status-error">请求失败</div>';
498
  }
499
+
500
  btn.disabled = false;
501
+ btn.textContent = '执行备份';
502
  }
503
+
504
  async function manualRestore() {
505
+ var select = document.getElementById('restoreSelect');
506
+ var backupName = select.value;
507
  if (!backupName) {
508
  alert('请先选择一个备份');
509
  return;
510
  }
511
+ if (!confirm('确认恢复?当前数据将被覆盖。')) return;
512
+
513
+ var resultDiv = document.getElementById('restoreResult');
514
+ resultDiv.innerHTML = '<div class="status status-info">正在恢复,请稍候...</div>';
515
+
516
+ try {
517
+ var response = await fetch('/api/restore', {
518
+ method: 'POST',
519
+ headers: { 'Content-Type': 'application/json' },
520
+ body: JSON.stringify({ backup_name: backupName })
521
+ });
522
+ var data = await response.json();
523
+ if (data.success) {
524
+ resultDiv.innerHTML = '<div class="status status-success">恢复完成</div>';
525
+ } else {
526
+ resultDiv.innerHTML = '<div class="status status-error">' + escapeHtml(data.error) + '</div>';
527
+ }
528
+ } catch (e) {
529
+ resultDiv.innerHTML = '<div class="status status-error">请求失败</div>';
530
  }
531
  }
532
+
 
533
  refreshBackupList();
 
534
  loadAutoStatus();
535
+ setInterval(refreshBackupList, 30000);
536
  setInterval(loadAutoStatus, 60000);
537
  </script>
538
  </body>
539
  </html>
540
  """
541
 
542
+
543
  def get_backup_files():
 
544
  try:
545
  api = HfApi()
546
+ repo_info = api.repo_info(repo_id=BACKUP_DATASET, repo_type="dataset")
547
+ siblings = getattr(repo_info, 'siblings', [])
548
+ backups = []
549
+ for f in siblings:
550
+ if f.rfilename.startswith("backup_") and f.rfilename.endswith(".tar.gz"):
551
+ backups.append({
552
+ "name": f.rfilename,
553
+ "date": f.rfilename.replace("backup_", "").replace(".tar.gz", ""),
554
+ "size": getattr(f, 'size', 0) or 0
555
+ })
556
+ backups.sort(key=lambda x: x["name"], reverse=True)
557
+ return backups
558
  except Exception as e:
559
  print(f"获取备份列表失败: {e}")
560
  return []
561
 
562
+
563
  def create_backup():
564
+ global _last_backup_time
565
  timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
566
  backup_name = f"backup_{timestamp}.tar.gz"
567
  backup_path = f"/tmp/{backup_name}"
568
+
569
  try:
 
570
  with tarfile.open(backup_path, "w:gz") as tar:
571
  tar.add(SOURCE_MOUNT, arcname="memos_data")
572
+
 
573
  api = HfApi()
574
  api.upload_file(
575
  path_or_fileobj=backup_path,
 
577
  repo_id=BACKUP_DATASET,
578
  repo_type="dataset"
579
  )
580
+
581
  os.remove(backup_path)
582
+ _last_backup_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
583
  return {"success": True, "backup_name": backup_name}
584
  except Exception as e:
585
  return {"success": False, "error": str(e)}
586
 
587
+
588
+ def safe_extract_tar(tar, path):
589
+ for member in tar.getmembers():
590
+ if member.name.startswith('/') or '..' in member.name.split('/'):
591
+ raise ValueError(f"不安全的路径: {member.name}")
592
+ member_path = os.path.join(path, member.name)
593
+ if not os.path.abspath(member_path).startswith(os.path.abspath(path) + os.sep):
594
+ raise ValueError(f"不安全的路径: {member.name}")
595
+ tar.extractall(path)
596
+
597
+
598
  @app.route('/')
599
  def index():
600
+ interval = auto_interval_seconds
601
+ if interval % 86400 == 0:
602
+ interval_value = interval // 86400
603
+ interval_unit = 'day'
604
+ elif interval % 3600 == 0:
605
+ interval_value = interval // 3600
606
+ interval_unit = 'hour'
607
+ else:
608
+ interval_value = interval // 60
609
+ interval_unit = 'minute'
610
+
611
  return render_template_string(
612
  HTML_TEMPLATE,
613
  source_path=SOURCE_MOUNT,
614
  backup_dataset=BACKUP_DATASET,
615
+ auto_interval_value=interval_value,
616
+ auto_interval_unit=interval_unit,
617
+ last_backup_time=_last_backup_time or '尚未备份'
618
  )
619
 
620
+
621
  @app.route('/api/backups')
622
  def list_backups():
623
  return jsonify(get_backup_files())
624
 
625
+
626
  @app.route('/api/backup', methods=['POST'])
627
  def do_backup():
628
+ return jsonify(create_backup())
629
+
630
 
631
  @app.route('/api/restore', methods=['POST'])
632
  def do_restore():
 
634
  backup_name = data.get('backup_name')
635
  if not backup_name:
636
  return jsonify({"success": False, "error": "未指定备份文件"})
637
+
638
  try:
639
  api = HfApi()
640
  backup_path = f"/tmp/{backup_name}"
 
644
  local_dir="/tmp",
645
  repo_type="dataset"
646
  )
647
+
648
  with tarfile.open(backup_path, "r:gz") as tar:
649
+ safe_extract_tar(tar, "/tmp/restore")
650
+
 
651
  for item in os.listdir("/tmp/restore/memos_data"):
652
  src = f"/tmp/restore/memos_data/{item}"
653
  dst = f"{SOURCE_MOUNT}/{item}"
 
657
  shutil.copytree(src, dst)
658
  else:
659
  shutil.copy2(src, dst)
660
+
661
  os.remove(backup_path)
662
+ shutil.rmtree("/tmp/restore", ignore_errors=True)
663
+
664
  return jsonify({"success": True, "message": f"已从 {backup_name} 恢复"})
665
  except Exception as e:
666
  return jsonify({"success": False, "error": str(e)})
667
 
668
+
669
  @app.route('/api/set_interval', methods=['POST'])
670
  def set_interval():
671
  global auto_interval_seconds
672
  data = request.json
673
  new_interval = data.get('interval_seconds')
674
+
675
  if not new_interval or new_interval < 60:
676
  return jsonify({"success": False, "error": "间隔不能小于 60 秒"})
677
+
678
  auto_interval_seconds = new_interval
679
  save_config()
680
+ restart_auto_backup()
681
+
 
 
 
 
 
 
682
  return jsonify({"success": True, "message": f"自动备份间隔已设为 {new_interval // 60} 分钟"})
683
 
684
+
685
  @app.route('/api/auto_status')
686
  def auto_status():
687
  next_time = datetime.datetime.now() + datetime.timedelta(seconds=auto_interval_seconds)
688
  return jsonify({
689
  "enabled": True,
690
  "interval_seconds": auto_interval_seconds,
691
+ "next_backup": next_time.strftime("%Y-%m-%d %H:%M:%S"),
692
+ "last_backup": _last_backup_time
693
  })
694
 
695
+
696
  def auto_backup_worker():
697
+ while not _auto_stop_event.is_set():
698
+ if _auto_stop_event.wait(timeout=auto_interval_seconds):
699
+ break
 
700
  with app.app_context():
701
  result = create_backup()
702
  print(f"[{datetime.datetime.now()}] 自动备份: {result}")
703
 
704
+
705
  def start_auto_backup_thread():
706
+ global _auto_thread
707
+ _auto_stop_event.clear()
708
+ _auto_thread = threading.Thread(target=auto_backup_worker, daemon=True)
709
+ _auto_thread.start()
710
+
711
+
712
+ def restart_auto_backup():
713
+ _auto_stop_event.set()
714
+ if _auto_thread and _auto_thread.is_alive():
715
+ _auto_thread.join(timeout=5)
716
+ start_auto_backup_thread()
717
+
718
 
 
719
  load_config()
720
  start_auto_backup_thread()
721
 
722
  if __name__ == '__main__':
723
+ app.run(host='0.0.0.0', port=7860)