memos-backup / app.py
sjfs's picture
Update app.py
7f4dc1a verified
Raw
History Blame Contribute Delete
23.9 kB
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)
@app.route('/')
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 '尚未备份'
)
@app.route('/api/backups')
def list_backups():
return jsonify(get_backup_files())
@app.route('/api/backup', methods=['POST'])
def do_backup():
return jsonify(create_backup())
@app.route('/api/restore', methods=['POST'])
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)})
@app.route('/api/set_interval', methods=['POST'])
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} 分钟"})
@app.route('/api/auto_status')
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)