Spaces:
Paused
Paused
| import os | |
| import tarfile | |
| import datetime | |
| import threading | |
| import time | |
| import json | |
| import shutil | |
| from flask import Flask, render_template_string, request, jsonify | |
| from huggingface_hub import HfApi | |
| app = Flask(__name__) | |
| SOURCE_MOUNT = "/mnt/data" | |
| BACKUP_DATASET = "sjfs/memos-backup-data" | |
| CONFIG_FILE = "/tmp/auto_backup_config.json" | |
| auto_interval_seconds = 86400 | |
| _last_backup_time = None | |
| _auto_stop_event = threading.Event() | |
| _auto_thread = None | |
| def load_config(): | |
| global auto_interval_seconds | |
| try: | |
| with open(CONFIG_FILE, 'r') as f: | |
| config = json.load(f) | |
| auto_interval_seconds = config.get('interval_seconds', 86400) | |
| except (FileNotFoundError, json.JSONDecodeError, OSError): | |
| pass | |
| def save_config(): | |
| try: | |
| with open(CONFIG_FILE, 'w') as f: | |
| json.dump({'interval_seconds': auto_interval_seconds}, f) | |
| except OSError: | |
| pass | |
| HTML_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Memos Backup</title> | |
| <style> | |
| :root { | |
| --bg: #F5F3EF; | |
| --surface: #FFFFFF; | |
| --border: #DDD9D2; | |
| --border-light: #EBE8E2; | |
| --text: #1F1F1F; | |
| --text-muted: #8A8A8A; | |
| --text-faint: #B5B5B5; | |
| --accent: #6B7280; | |
| --accent-hover: #555D67; | |
| --accent-surface: #F2F3F5; | |
| --green: #6B8F71; | |
| --green-surface: #EFF5F0; | |
| --red: #B86B6B; | |
| --red-surface: #F5F0F0; | |
| --blue: #6B8BA4; | |
| --blue-surface: #EFF4F7; | |
| --radius: 5px; | |
| --transition: 0.2s ease; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, 'Hiragino Sans', 'Noto Sans SC', 'Yu Gothic', sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-size: 14px; | |
| line-height: 1.6; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| .top-bar { | |
| height: 3px; | |
| background: var(--accent); | |
| } | |
| .container { | |
| max-width: 680px; | |
| margin: 0 auto; | |
| padding: 40px 20px 60px; | |
| } | |
| .header { | |
| margin-bottom: 36px; | |
| } | |
| .header h1 { | |
| font-size: 22px; | |
| font-weight: 600; | |
| letter-spacing: -0.02em; | |
| color: var(--text); | |
| margin-bottom: 8px; | |
| } | |
| .header h1::after { | |
| content: ''; | |
| display: block; | |
| width: 24px; | |
| height: 2px; | |
| background: var(--accent); | |
| margin-top: 10px; | |
| } | |
| .header-meta { | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| margin-top: 10px; | |
| } | |
| .header-meta code { | |
| background: var(--accent-surface); | |
| padding: 2px 6px; | |
| border-radius: 3px; | |
| font-size: 11px; | |
| color: var(--accent); | |
| font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; | |
| } | |
| .header-meta .sep { | |
| margin: 0 6px; | |
| color: var(--text-faint); | |
| } | |
| .card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 24px; | |
| margin-bottom: 16px; | |
| transition: border-color var(--transition); | |
| } | |
| .card:hover { | |
| border-color: #CCC8C0; | |
| } | |
| .card-title { | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| color: var(--text-muted); | |
| margin-bottom: 16px; | |
| } | |
| .btn { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 9px 20px; | |
| border: none; | |
| border-radius: var(--radius); | |
| font-size: 13px; | |
| font-weight: 500; | |
| letter-spacing: 0.02em; | |
| cursor: pointer; | |
| transition: background var(--transition), opacity var(--transition); | |
| background: var(--accent); | |
| color: #fff; | |
| font-family: inherit; | |
| } | |
| .btn:hover { background: var(--accent-hover); } | |
| .btn:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .btn-danger { background: var(--red); } | |
| .btn-danger:hover { background: #A55D5D; } | |
| input, select { | |
| padding: 8px 12px; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| font-size: 13px; | |
| color: var(--text); | |
| background: var(--surface); | |
| transition: border-color var(--transition); | |
| font-family: inherit; | |
| } | |
| input:focus, select:focus { | |
| outline: none; | |
| border-color: var(--accent); | |
| } | |
| .status { | |
| margin-top: 12px; | |
| padding: 10px 14px; | |
| border-radius: var(--radius); | |
| font-size: 13px; | |
| line-height: 1.5; | |
| animation: fadeIn 0.3s ease; | |
| } | |
| .status-success { background: var(--green-surface); color: var(--green); } | |
| .status-error { background: var(--red-surface); color: var(--red); } | |
| .status-info { background: var(--blue-surface); color: var(--blue); } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(-4px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .backup-list { list-style: none; } | |
| .backup-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 10px 0; | |
| border-bottom: 1px solid var(--border-light); | |
| font-size: 13px; | |
| } | |
| .backup-item:last-child { border-bottom: none; } | |
| .backup-date { | |
| color: var(--text); | |
| font-size: 13px; | |
| font-weight: 500; | |
| } | |
| .backup-filename { | |
| color: var(--text-faint); | |
| font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; | |
| font-size: 11px; | |
| margin-top: 2px; | |
| } | |
| .backup-size { | |
| color: var(--text-muted); | |
| font-size: 12px; | |
| white-space: nowrap; | |
| } | |
| .empty-state { | |
| text-align: center; | |
| padding: 24px; | |
| color: var(--text-faint); | |
| font-size: 13px; | |
| } | |
| .auto-config { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .auto-config label { | |
| font-size: 13px; | |
| color: var(--text-muted); | |
| } | |
| .auto-status { | |
| margin-top: 14px; | |
| padding-top: 14px; | |
| border-top: 1px solid var(--border-light); | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| line-height: 1.8; | |
| } | |
| .pulse { | |
| display: inline-block; | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: var(--green); | |
| margin-right: 6px; | |
| vertical-align: middle; | |
| animation: pulse 2s ease-in-out infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.3; } | |
| } | |
| .warning-block { | |
| font-size: 12px; | |
| color: var(--red); | |
| margin-bottom: 14px; | |
| padding: 10px 14px; | |
| background: var(--red-surface); | |
| border-radius: var(--radius); | |
| border-left: 3px solid var(--red); | |
| line-height: 1.5; | |
| } | |
| .restore-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| @media (max-width: 480px) { | |
| .container { padding: 24px 16px 40px; } | |
| .card { padding: 18px; } | |
| .auto-config { flex-direction: column; align-items: stretch; } | |
| .restore-row { flex-direction: column; align-items: stretch; } | |
| .restore-row .btn { width: 100%; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="top-bar"></div> | |
| <div class="container"> | |
| <header class="header"> | |
| <h1>Memos Backup</h1> | |
| <div class="header-meta"> | |
| 源 <code>{{ source_path }}</code><span class="sep">→</span>目标 <code>{{ backup_dataset }}</code> | |
| </div> | |
| </header> | |
| <section class="card"> | |
| <div class="card-title">手动备份</div> | |
| <button class="btn" id="backupBtn" onclick="manualBackup()">执行备份</button> | |
| <div id="backupResult"></div> | |
| </section> | |
| <section class="card"> | |
| <div class="card-title">自动备份</div> | |
| <div class="auto-config"> | |
| <label>间隔</label> | |
| <input type="number" id="intervalInput" value="{{ auto_interval_value }}" min="1" style="width:80px;"> | |
| <select id="intervalUnit"> | |
| <option value="minute" {% if auto_interval_unit == 'minute' %}selected{% endif %}>分钟</option> | |
| <option value="hour" {% if auto_interval_unit == 'hour' %}selected{% endif %}>小时</option> | |
| <option value="day" {% if auto_interval_unit == 'day' %}selected{% endif %}>天</option> | |
| </select> | |
| <button class="btn" onclick="setAutoInterval()">保存</button> | |
| </div> | |
| <div id="intervalResult"></div> | |
| <div class="auto-status" id="autoStatus">加载中...</div> | |
| </section> | |
| <section class="card"> | |
| <div class="card-title">备份记录</div> | |
| <div id="backupList"> | |
| <div class="empty-state">加载中...</div> | |
| </div> | |
| </section> | |
| <section class="card"> | |
| <div class="card-title">恢复数据</div> | |
| <div class="warning-block">恢复操作将覆盖当前数据,此操作不可逆。</div> | |
| <div class="restore-row"> | |
| <select id="restoreSelect" style="flex:1;"> | |
| <option value="">选择备份文件</option> | |
| </select> | |
| <button class="btn btn-danger" onclick="manualRestore()">恢复</button> | |
| </div> | |
| <div id="restoreResult"></div> | |
| </section> | |
| </div> | |
| <script> | |
| function escapeHtml(str) { | |
| var div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| } | |
| function formatDate(dateStr) { | |
| var parts = dateStr.split('_'); | |
| if (parts.length !== 2) return dateStr; | |
| var d = parts[0], t = parts[1]; | |
| if (d.length !== 8 || t.length !== 6) return dateStr; | |
| return d.substr(0,4) + '-' + d.substr(4,2) + '-' + d.substr(6,2) + ' ' + | |
| t.substr(0,2) + ':' + t.substr(2,2) + ':' + t.substr(4,2); | |
| } | |
| function formatInterval(seconds) { | |
| if (seconds >= 86400 && seconds % 86400 === 0) return (seconds / 86400) + ' 天'; | |
| if (seconds >= 3600 && seconds % 3600 === 0) return (seconds / 3600) + ' 小时'; | |
| return Math.round(seconds / 60) + ' 分钟'; | |
| } | |
| function formatSize(bytes) { | |
| if (!bytes) return '--'; | |
| if (bytes < 1024) return bytes + ' B'; | |
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; | |
| return (bytes / 1024 / 1024).toFixed(2) + ' MB'; | |
| } | |
| async function loadAutoStatus() { | |
| try { | |
| var response = await fetch('/api/auto_status'); | |
| var data = await response.json(); | |
| var el = document.getElementById('autoStatus'); | |
| if (data.enabled) { | |
| el.innerHTML = '<span class="pulse"></span>已启用 · 间隔 ' + formatInterval(data.interval_seconds) + | |
| '<br>下次备份 ' + escapeHtml(data.next_backup) + | |
| (data.last_backup ? '<br>上次备份 ' + escapeHtml(data.last_backup) : ''); | |
| } else { | |
| el.innerHTML = '未启用'; | |
| } | |
| } catch (e) { | |
| document.getElementById('autoStatus').innerHTML = '状态获取失败'; | |
| } | |
| } | |
| async function setAutoInterval() { | |
| var value = document.getElementById('intervalInput').value; | |
| var unit = document.getElementById('intervalUnit').value; | |
| var seconds = parseInt(value); | |
| if (isNaN(seconds) || seconds < 1) { | |
| alert('请输入有效的数字'); | |
| return; | |
| } | |
| if (unit === 'hour') seconds *= 3600; | |
| if (unit === 'day') seconds *= 86400; | |
| if (seconds < 60) { | |
| alert('间隔不能小于 1 分钟'); | |
| return; | |
| } | |
| var response = await fetch('/api/set_interval', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ interval_seconds: seconds }) | |
| }); | |
| var data = await response.json(); | |
| var el = document.getElementById('intervalResult'); | |
| if (data.success) { | |
| el.innerHTML = '<div class="status status-success">设置已保存</div>'; | |
| loadAutoStatus(); | |
| setTimeout(function() { el.innerHTML = ''; }, 3000); | |
| } else { | |
| el.innerHTML = '<div class="status status-error">' + escapeHtml(data.error) + '</div>'; | |
| } | |
| } | |
| async function refreshBackupList() { | |
| try { | |
| var response = await fetch('/api/backups'); | |
| var backups = await response.json(); | |
| var listDiv = document.getElementById('backupList'); | |
| var select = document.getElementById('restoreSelect'); | |
| if (backups.length === 0) { | |
| listDiv.innerHTML = '<div class="empty-state">暂无备份记录</div>'; | |
| select.innerHTML = '<option value="">暂无备份</option>'; | |
| return; | |
| } | |
| var html = '<ul class="backup-list">'; | |
| select.innerHTML = '<option value="">选择备份文件</option>'; | |
| for (var i = 0; i < backups.length; i++) { | |
| var b = backups[i]; | |
| var dateFormatted = formatDate(b.date); | |
| var sizeFormatted = formatSize(b.size); | |
| html += '<li class="backup-item">' + | |
| '<div><div class="backup-date">' + escapeHtml(dateFormatted) + '</div>' + | |
| '<div class="backup-filename">' + escapeHtml(b.name) + '</div></div>' + | |
| '<div class="backup-size">' + sizeFormatted + '</div></li>'; | |
| select.innerHTML += '<option value="' + escapeHtml(b.name) + '">' + | |
| escapeHtml(dateFormatted) + ' (' + sizeFormatted + ')</option>'; | |
| } | |
| html += '</ul>'; | |
| listDiv.innerHTML = html; | |
| } catch (e) { | |
| document.getElementById('backupList').innerHTML = '<div class="empty-state">加载失败</div>'; | |
| } | |
| } | |
| async function manualBackup() { | |
| var btn = document.getElementById('backupBtn'); | |
| var resultDiv = document.getElementById('backupResult'); | |
| btn.disabled = true; | |
| btn.textContent = '备份中...'; | |
| resultDiv.innerHTML = '<div class="status status-info">正在备份,请稍候...</div>'; | |
| try { | |
| var response = await fetch('/api/backup', { method: 'POST' }); | |
| var data = await response.json(); | |
| if (data.success) { | |
| resultDiv.innerHTML = '<div class="status status-success">备份完成 · ' + escapeHtml(data.backup_name || '') + '</div>'; | |
| refreshBackupList(); | |
| loadAutoStatus(); | |
| } else { | |
| resultDiv.innerHTML = '<div class="status status-error">' + escapeHtml(data.error) + '</div>'; | |
| } | |
| } catch (e) { | |
| resultDiv.innerHTML = '<div class="status status-error">请求失败</div>'; | |
| } | |
| btn.disabled = false; | |
| btn.textContent = '执行备份'; | |
| } | |
| async function manualRestore() { | |
| var select = document.getElementById('restoreSelect'); | |
| var backupName = select.value; | |
| if (!backupName) { | |
| alert('请先选择一个备份'); | |
| return; | |
| } | |
| if (!confirm('确认恢复?当前数据将被覆盖。')) return; | |
| var resultDiv = document.getElementById('restoreResult'); | |
| resultDiv.innerHTML = '<div class="status status-info">正在恢复,请稍候...</div>'; | |
| try { | |
| var response = await fetch('/api/restore', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ backup_name: backupName }) | |
| }); | |
| var data = await response.json(); | |
| if (data.success) { | |
| resultDiv.innerHTML = '<div class="status status-success">恢复完成</div>'; | |
| } else { | |
| resultDiv.innerHTML = '<div class="status status-error">' + escapeHtml(data.error) + '</div>'; | |
| } | |
| } catch (e) { | |
| resultDiv.innerHTML = '<div class="status status-error">请求失败</div>'; | |
| } | |
| } | |
| refreshBackupList(); | |
| loadAutoStatus(); | |
| setInterval(refreshBackupList, 30000); | |
| setInterval(loadAutoStatus, 60000); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def get_backup_files(): | |
| try: | |
| api = HfApi() | |
| repo_info = api.repo_info(repo_id=BACKUP_DATASET, repo_type="dataset") | |
| siblings = getattr(repo_info, 'siblings', []) | |
| backups = [] | |
| for f in siblings: | |
| if f.rfilename.startswith("backup_") and f.rfilename.endswith(".tar.gz"): | |
| backups.append({ | |
| "name": f.rfilename, | |
| "date": f.rfilename.replace("backup_", "").replace(".tar.gz", ""), | |
| "size": getattr(f, 'size', 0) or 0 | |
| }) | |
| backups.sort(key=lambda x: x["name"], reverse=True) | |
| return backups | |
| except Exception as e: | |
| print(f"获取备份列表失败: {e}") | |
| return [] | |
| def create_backup(): | |
| global _last_backup_time | |
| timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") | |
| backup_name = f"backup_{timestamp}.tar.gz" | |
| backup_path = f"/tmp/{backup_name}" | |
| try: | |
| with tarfile.open(backup_path, "w:gz") as tar: | |
| tar.add(SOURCE_MOUNT, arcname="memos_data") | |
| api = HfApi() | |
| api.upload_file( | |
| path_or_fileobj=backup_path, | |
| path_in_repo=backup_name, | |
| repo_id=BACKUP_DATASET, | |
| repo_type="dataset" | |
| ) | |
| os.remove(backup_path) | |
| _last_backup_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| return {"success": True, "backup_name": backup_name} | |
| except Exception as e: | |
| return {"success": False, "error": str(e)} | |
| def safe_extract_tar(tar, path): | |
| for member in tar.getmembers(): | |
| if member.name.startswith('/') or '..' in member.name.split('/'): | |
| raise ValueError(f"不安全的路径: {member.name}") | |
| member_path = os.path.join(path, member.name) | |
| if not os.path.abspath(member_path).startswith(os.path.abspath(path) + os.sep): | |
| raise ValueError(f"不安全的路径: {member.name}") | |
| tar.extractall(path) | |
| def index(): | |
| interval = auto_interval_seconds | |
| if interval % 86400 == 0: | |
| interval_value = interval // 86400 | |
| interval_unit = 'day' | |
| elif interval % 3600 == 0: | |
| interval_value = interval // 3600 | |
| interval_unit = 'hour' | |
| else: | |
| interval_value = interval // 60 | |
| interval_unit = 'minute' | |
| return render_template_string( | |
| HTML_TEMPLATE, | |
| source_path=SOURCE_MOUNT, | |
| backup_dataset=BACKUP_DATASET, | |
| auto_interval_value=interval_value, | |
| auto_interval_unit=interval_unit, | |
| last_backup_time=_last_backup_time or '尚未备份' | |
| ) | |
| def list_backups(): | |
| return jsonify(get_backup_files()) | |
| def do_backup(): | |
| return jsonify(create_backup()) | |
| def do_restore(): | |
| data = request.json | |
| backup_name = data.get('backup_name') | |
| if not backup_name: | |
| return jsonify({"success": False, "error": "未指定备份文件"}) | |
| try: | |
| api = HfApi() | |
| backup_path = f"/tmp/{backup_name}" | |
| api.hf_hub_download( | |
| repo_id=BACKUP_DATASET, | |
| filename=backup_name, | |
| local_dir="/tmp", | |
| repo_type="dataset" | |
| ) | |
| with tarfile.open(backup_path, "r:gz") as tar: | |
| safe_extract_tar(tar, "/tmp/restore") | |
| for item in os.listdir("/tmp/restore/memos_data"): | |
| src = f"/tmp/restore/memos_data/{item}" | |
| dst = f"{SOURCE_MOUNT}/{item}" | |
| if os.path.isdir(src): | |
| if os.path.exists(dst): | |
| shutil.rmtree(dst) | |
| shutil.copytree(src, dst) | |
| else: | |
| shutil.copy2(src, dst) | |
| os.remove(backup_path) | |
| shutil.rmtree("/tmp/restore", ignore_errors=True) | |
| return jsonify({"success": True, "message": f"已从 {backup_name} 恢复"}) | |
| except Exception as e: | |
| return jsonify({"success": False, "error": str(e)}) | |
| def set_interval(): | |
| global auto_interval_seconds | |
| data = request.json | |
| new_interval = data.get('interval_seconds') | |
| if not new_interval or new_interval < 60: | |
| return jsonify({"success": False, "error": "间隔不能小于 60 秒"}) | |
| auto_interval_seconds = new_interval | |
| save_config() | |
| restart_auto_backup() | |
| return jsonify({"success": True, "message": f"自动备份间隔已设为 {new_interval // 60} 分钟"}) | |
| def auto_status(): | |
| next_time = datetime.datetime.now() + datetime.timedelta(seconds=auto_interval_seconds) | |
| return jsonify({ | |
| "enabled": True, | |
| "interval_seconds": auto_interval_seconds, | |
| "next_backup": next_time.strftime("%Y-%m-%d %H:%M:%S"), | |
| "last_backup": _last_backup_time | |
| }) | |
| def auto_backup_worker(): | |
| while not _auto_stop_event.is_set(): | |
| if _auto_stop_event.wait(timeout=auto_interval_seconds): | |
| break | |
| with app.app_context(): | |
| result = create_backup() | |
| print(f"[{datetime.datetime.now()}] 自动备份: {result}") | |
| def start_auto_backup_thread(): | |
| global _auto_thread | |
| _auto_stop_event.clear() | |
| _auto_thread = threading.Thread(target=auto_backup_worker, daemon=True) | |
| _auto_thread.start() | |
| def restart_auto_backup(): | |
| _auto_stop_event.set() | |
| if _auto_thread and _auto_thread.is_alive(): | |
| _auto_thread.join(timeout=5) | |
| start_auto_backup_thread() | |
| load_config() | |
| start_auto_backup_thread() | |
| if __name__ == '__main__': | |
| app.run(host='0.0.0.0', port=7860) | |