root commited on
Commit
7d4bbcc
·
1 Parent(s): 77cff06

feat: implement codebase and data management system with HF integration

Browse files

- Created /manage interface for switching code versions and restoring data backups.
- Integrated Hugging Face Dataset API to list backup snapshots and restore specific revisions.
- Enhanced hf_sync.py with a default repo (Jaimodiji/Report-Generator-Data) and simulation mode via RPT_DEBUG.
- Added data protection: automated backups before any code switch or data restoration.
- Updated entrypoint.sh to support default repo ID and debug simulation.
- Added 'Manage Codebase' link to navbar dropdown.

entrypoint.sh CHANGED
@@ -7,13 +7,11 @@ cd /app
7
  python3 hf_sync.py init
8
 
9
  # 2. Try to download existing data
10
- if [ -n "$DATASET_REPO_ID" ]; then
11
- python3 hf_sync.py download
12
- else
13
- echo "DATASET_REPO_ID not set, skipping initial download."
14
- fi
15
 
16
  # 3. Setup symlinks
 
17
  # Remove existing if any
18
  rm -rf database.db output processed uploads
19
 
@@ -27,16 +25,22 @@ ln -sf data_repo/processed processed
27
  ln -sf data_repo/uploads uploads
28
 
29
  # 4. Start periodic background upload
30
- if [ -n "$DATASET_REPO_ID" ] && [ -n "$HF_TOKEN" ]; then
31
  (
32
  while true; do
33
  sleep 120 # Every 2 minutes
34
- echo "Performing scheduled backup to HF Datasets..."
 
 
 
 
 
 
35
  python3 hf_sync.py upload
36
  done
37
  ) &
38
  else
39
- echo "DATASET_REPO_ID or HF_TOKEN not set, periodic backup disabled."
40
  fi
41
 
42
  # 5. Start the application
 
7
  python3 hf_sync.py init
8
 
9
  # 2. Try to download existing data
10
+ # REPO_ID defaults to Jaimodiji/Report-Generator-Data in hf_sync.py
11
+ python3 hf_sync.py download
 
 
 
12
 
13
  # 3. Setup symlinks
14
+ # ... (rest of symlink setup)
15
  # Remove existing if any
16
  rm -rf database.db output processed uploads
17
 
 
25
  ln -sf data_repo/uploads uploads
26
 
27
  # 4. Start periodic background upload
28
+ if [ -n "$HF_TOKEN" ]; then
29
  (
30
  while true; do
31
  sleep 120 # Every 2 minutes
32
+ if [[ "${RPT_DEBUG,,}" == "true" || "${RPT_DEBUG}" == "1" || "${RPT_DEBUG,,}" == "t" ]]; then
33
+ echo "[DEBUG] Simulating scheduled backup to HF Datasets..."
34
+ else
35
+ echo "Performing scheduled backup to HF Datasets..."
36
+ fi
37
+ # REPO_ID defaults to Jaimodiji/Report-Generator-Data in hf_sync.py
38
+ # hf_sync.py also respects RPT_DEBUG internally
39
  python3 hf_sync.py upload
40
  done
41
  ) &
42
  else
43
+ echo "HF_TOKEN not set, periodic backup disabled."
44
  fi
45
 
46
  # 5. Start the application
hf_sync.py CHANGED
@@ -9,8 +9,9 @@ from datetime import datetime
9
  from huggingface_hub import snapshot_download, HfApi
10
 
11
  # Configuration
12
- REPO_ID = os.environ.get("DATASET_REPO_ID")
13
  HF_TOKEN = os.environ.get("HF_TOKEN")
 
14
  DATA_DIR = "data_repo"
15
  DB_FILE = os.path.join(DATA_DIR, "database.db")
16
  STATE_FILE = os.path.join(DATA_DIR, "sync_state.json")
@@ -66,9 +67,12 @@ def upload():
66
  if backup_path:
67
  db_hash = get_file_hash(backup_path)
68
  if db_hash != state.get("last_db_hash"):
69
- print("Syncing Database...")
70
- # Upload the backup file directly without replacing the active database
71
- api.upload_file(path_or_fileobj=backup_path, path_in_repo="database.db", repo_id=REPO_ID, repo_type="dataset")
 
 
 
72
  state["last_db_hash"] = db_hash
73
  changes_made = True
74
  # Clean up the backup file regardless
@@ -91,14 +95,17 @@ def upload():
91
  file_id = f"{rel_path}_{size}_{mtime}"
92
 
93
  if state["uploaded_files"].get(rel_path) != file_id:
94
- print(f"Syncing new file: {rel_path}")
95
  try:
96
- api.upload_file(
97
- path_or_fileobj=full_path,
98
- path_in_repo=rel_path,
99
- repo_id=REPO_ID,
100
- repo_type="dataset"
101
- )
 
 
 
102
  state["uploaded_files"][rel_path] = file_id
103
  changes_made = True
104
  except Exception as e:
@@ -108,20 +115,31 @@ def upload():
108
  state["version"] += 1
109
  save_state(state)
110
  # Sync state file too
111
- api.upload_file(path_or_fileobj=STATE_FILE, path_in_repo="sync_state.json", repo_id=REPO_ID, repo_type="dataset")
112
- print(f"Sync complete. Version {state['version']} saved.")
 
 
 
113
  else:
114
- print("Everything up to date.")
115
 
116
  except Exception as e: print(f"Upload process failed: {e}")
117
  finally:
118
  if os.path.exists(LOCK_FILE): os.remove(LOCK_FILE)
119
 
120
- def download():
121
  if not REPO_ID: return
122
- print(f"Downloading data from {REPO_ID}...")
 
123
  try:
124
- snapshot_download(repo_id=REPO_ID, repo_type="dataset", local_dir=DATA_DIR, token=HF_TOKEN, max_workers=8)
 
 
 
 
 
 
 
125
  print("Download successful.")
126
  except Exception as e: print(f"Download failed: {e}")
127
 
@@ -130,7 +148,9 @@ def init_local():
130
 
131
  if __name__ == "__main__":
132
  action = sys.argv[1] if len(sys.argv) > 1 else "help"
133
- if action == "download": download()
 
 
134
  elif action == "upload": upload()
135
  elif action == "init": init_local()
136
- else: print("Usage: python hf_sync.py [download|upload|init]")
 
9
  from huggingface_hub import snapshot_download, HfApi
10
 
11
  # Configuration
12
+ REPO_ID = os.environ.get("DATASET_REPO_ID", "Jaimodiji/Report-Generator-Data")
13
  HF_TOKEN = os.environ.get("HF_TOKEN")
14
+ DEBUG_MODE = os.environ.get("RPT_DEBUG", "false").lower() in ("true", "1", "t")
15
  DATA_DIR = "data_repo"
16
  DB_FILE = os.path.join(DATA_DIR, "database.db")
17
  STATE_FILE = os.path.join(DATA_DIR, "sync_state.json")
 
67
  if backup_path:
68
  db_hash = get_file_hash(backup_path)
69
  if db_hash != state.get("last_db_hash"):
70
+ print(f"{'[DEBUG] ' if DEBUG_MODE else ''}Syncing Database...")
71
+ if not DEBUG_MODE:
72
+ # Upload the backup file directly without replacing the active database
73
+ api.upload_file(path_or_fileobj=backup_path, path_in_repo="database.db", repo_id=REPO_ID, repo_type="dataset")
74
+ else:
75
+ print(f"[DEBUG] Simulated upload of {backup_path} to {REPO_ID}/database.db")
76
  state["last_db_hash"] = db_hash
77
  changes_made = True
78
  # Clean up the backup file regardless
 
95
  file_id = f"{rel_path}_{size}_{mtime}"
96
 
97
  if state["uploaded_files"].get(rel_path) != file_id:
98
+ print(f"{'[DEBUG] ' if DEBUG_MODE else ''}Syncing new file: {rel_path}")
99
  try:
100
+ if not DEBUG_MODE:
101
+ api.upload_file(
102
+ path_or_fileobj=full_path,
103
+ path_in_repo=rel_path,
104
+ repo_id=REPO_ID,
105
+ repo_type="dataset"
106
+ )
107
+ else:
108
+ print(f"[DEBUG] Simulated upload of {rel_path} to {REPO_ID}/{rel_path}")
109
  state["uploaded_files"][rel_path] = file_id
110
  changes_made = True
111
  except Exception as e:
 
115
  state["version"] += 1
116
  save_state(state)
117
  # Sync state file too
118
+ if not DEBUG_MODE:
119
+ api.upload_file(path_or_fileobj=STATE_FILE, path_in_repo="sync_state.json", repo_id=REPO_ID, repo_type="dataset")
120
+ else:
121
+ print(f"[DEBUG] Simulated upload of {STATE_FILE} to {REPO_ID}/sync_state.json")
122
+ print(f"{'[DEBUG] ' if DEBUG_MODE else ''}Sync complete. Version {state['version']} saved.")
123
  else:
124
+ print(f"{'[DEBUG] ' if DEBUG_MODE else ''}Everything up to date.")
125
 
126
  except Exception as e: print(f"Upload process failed: {e}")
127
  finally:
128
  if os.path.exists(LOCK_FILE): os.remove(LOCK_FILE)
129
 
130
+ def download(revision=None):
131
  if not REPO_ID: return
132
+ rev_str = f" (revision: {revision})" if revision else ""
133
+ print(f"Downloading data from {REPO_ID}{rev_str}...")
134
  try:
135
+ snapshot_download(
136
+ repo_id=REPO_ID,
137
+ repo_type="dataset",
138
+ local_dir=DATA_DIR,
139
+ token=HF_TOKEN,
140
+ revision=revision,
141
+ max_workers=8
142
+ )
143
  print("Download successful.")
144
  except Exception as e: print(f"Download failed: {e}")
145
 
 
148
 
149
  if __name__ == "__main__":
150
  action = sys.argv[1] if len(sys.argv) > 1 else "help"
151
+ if action == "download":
152
+ revision = sys.argv[2] if len(sys.argv) > 2 else None
153
+ download(revision)
154
  elif action == "upload": upload()
155
  elif action == "init": init_local()
156
+ else: print("Usage: python hf_sync.py [download [revision]|upload|init]")
requirements.txt CHANGED
@@ -23,3 +23,4 @@ jsonschema
23
  gdown
24
  google-api-python-client
25
  google-auth-oauthlib
 
 
23
  gdown
24
  google-api-python-client
25
  google-auth-oauthlib
26
+ huggingface_hub
routes/general.py CHANGED
@@ -1,8 +1,134 @@
1
  import os
2
- from flask import jsonify, render_template, redirect, url_for, current_app, request
 
3
  from .common import main_bp, get_db_connection, login_required, current_user, upload_progress
4
  from strings import ROUTE_INDEX, METHOD_DELETE, METHOD_POST
 
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  @main_bp.route(ROUTE_INDEX)
7
  def index():
8
  if current_user.is_authenticated: return redirect(url_for('dashboard.dashboard'))
 
1
  import os
2
+ import subprocess
3
+ from flask import jsonify, render_template, redirect, url_for, current_app, request, flash
4
  from .common import main_bp, get_db_connection, login_required, current_user, upload_progress
5
  from strings import ROUTE_INDEX, METHOD_DELETE, METHOD_POST
6
+ from huggingface_hub import HfApi
7
 
8
+ def get_hf_backup_info():
9
+ try:
10
+ repo_id = os.environ.get("DATASET_REPO_ID", "Jaimodiji/Report-Generator-Data")
11
+ token = os.environ.get("HF_TOKEN")
12
+ if not token:
13
+ return [], repo_id
14
+
15
+ api = HfApi(token=token)
16
+ commits = api.list_repo_commits(repo_id=repo_id, repo_type="dataset")
17
+
18
+ hf_commits = []
19
+ for c in commits[:20]: # Last 20 backups
20
+ hf_commits.append({
21
+ 'hash': c.commit_id,
22
+ 'author': c.authors[0] if c.authors else "Unknown",
23
+ 'date': c.created_at.strftime('%Y-%m-%d %H:%M:%S'),
24
+ 'message': c.title
25
+ })
26
+ return hf_commits, repo_id
27
+ except Exception as e:
28
+ current_app.logger.error(f"HF backup info error: {e}")
29
+ return [], os.environ.get("DATASET_REPO_ID", "Jaimodiji/Report-Generator-Data")
30
+
31
+ def get_git_info():
32
+ try:
33
+ # Get last 20 commits
34
+ log_output = subprocess.check_output(
35
+ ['git', 'log', '-n', '20', '--pretty=format:%h|%an|%ar|%s'],
36
+ stderr=subprocess.STDOUT, text=True
37
+ )
38
+ commits = []
39
+ for line in log_output.split('\n'):
40
+ if not line: continue
41
+ h, an, ar, s = line.split('|', 3)
42
+ commits.append({'hash': h, 'author': an, 'date': ar, 'message': s})
43
+
44
+ # Get current hash and branch
45
+ current_hash = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'], text=True).strip()
46
+ try:
47
+ current_branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], text=True).strip()
48
+ except:
49
+ current_branch = "Detached"
50
+
51
+ # Check for uncommitted changes
52
+ status_output = subprocess.check_output(['git', 'status', '--porcelain'], text=True).strip()
53
+ uncommitted = bool(status_output)
54
+
55
+ return commits, current_hash, current_branch, uncommitted
56
+ except Exception as e:
57
+ current_app.logger.error(f"Git info error: {e}")
58
+ return [], "Unknown", "Unknown", False
59
+
60
+ @main_bp.route('/manage')
61
+ @login_required
62
+ def manage():
63
+ code_commits, current_hash, current_branch, uncommitted = get_git_info()
64
+ hf_commits, hf_repo_id = get_hf_backup_info()
65
+ return render_template('manage.html',
66
+ code_commits=code_commits,
67
+ hf_commits=hf_commits,
68
+ current_hash=current_hash,
69
+ current_branch=current_branch,
70
+ uncommitted_changes=uncommitted,
71
+ hf_repo_id=hf_repo_id)
72
+
73
+ @main_bp.route('/manage/checkout', methods=['POST'])
74
+ @login_required
75
+ def manage_checkout():
76
+ target_hash = request.form.get('hash')
77
+ if not target_hash:
78
+ flash("No commit hash provided", "danger")
79
+ return redirect(url_for('main.manage'))
80
+
81
+ try:
82
+ # 1. Protection: Trigger immediate data backup before code change
83
+ current_app.logger.info(f"Triggering backup before switch to {target_hash}")
84
+ subprocess.run(['python3', 'hf_sync.py', 'upload'], check=True)
85
+
86
+ # 2. Protection: Stash uncommitted code changes
87
+ subprocess.run(['git', 'stash'], check=False)
88
+
89
+ # 3. Checkout target code commit
90
+ current_app.logger.info(f"Checking out code commit {target_hash}")
91
+ subprocess.run(['git', 'checkout', target_hash], check=True)
92
+
93
+ flash(f"Successfully switched code to {target_hash}. Application will restart.", "success")
94
+ except Exception as e:
95
+ current_app.logger.error(f"Checkout error: {e}")
96
+ flash(f"Error during code switch: {str(e)}", "danger")
97
+
98
+ return redirect(url_for('main.manage'))
99
+
100
+ @main_bp.route('/manage/restore', methods=['POST'])
101
+ @login_required
102
+ def manage_restore():
103
+ target_hash = request.form.get('hash')
104
+ if not target_hash:
105
+ flash("No backup hash provided", "danger")
106
+ return redirect(url_for('main.manage'))
107
+
108
+ try:
109
+ # 1. Protection: Backup current state before restoring old one
110
+ current_app.logger.info(f"Triggering safety backup before restoring {target_hash}")
111
+ subprocess.run(['python3', 'hf_sync.py', 'upload'], check=True)
112
+
113
+ # 2. Restore data from HF at specific revision
114
+ current_app.logger.info(f"Restoring data from HF revision {target_hash}")
115
+ subprocess.run(['python3', 'hf_sync.py', 'download', target_hash], check=True)
116
+
117
+ flash(f"Successfully restored data from backup {target_hash[:8]}. Application will restart with old data.", "success")
118
+ except Exception as e:
119
+ current_app.logger.error(f"Restore error: {e}")
120
+ flash(f"Error during data restoration: {str(e)}", "danger")
121
+
122
+ return redirect(url_for('main.manage'))
123
+
124
+ @main_bp.route('/manage/backup', methods=['POST'])
125
+ @login_required
126
+ def manage_backup():
127
+ try:
128
+ subprocess.run(['python3', 'hf_sync.py', 'upload'], check=True)
129
+ return jsonify({'success': True})
130
+ except Exception as e:
131
+ return jsonify({'error': str(e)}), 500
132
  @main_bp.route(ROUTE_INDEX)
133
  def index():
134
  if current_user.is_authenticated: return redirect(url_for('dashboard.dashboard'))
templates/_nav_links.html CHANGED
@@ -33,6 +33,7 @@
33
  </a>
34
  <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
35
  <li><a class="dropdown-item" href="{{ url_for('settings.settings') }}">Settings</a></li>
 
36
  <li><hr class="dropdown-divider"></li>
37
  <li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
38
  </ul>
 
33
  </a>
34
  <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
35
  <li><a class="dropdown-item" href="{{ url_for('settings.settings') }}">Settings</a></li>
36
+ <li><a class="dropdown-item" href="{{ url_for('main.manage') }}"><i class="bi bi-git me-1"></i> Manage Codebase</a></li>
37
  <li><hr class="dropdown-divider"></li>
38
  <li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
39
  </ul>
templates/manage.html ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Manage Codebase & Data{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .commit-row { cursor: pointer; transition: background 0.2s; }
8
+ .commit-row:hover { background: rgba(255,255,255,0.05); }
9
+ .commit-row.current { border-left: 4px solid var(--accent-success); background: rgba(25, 135, 84, 0.1); }
10
+ .hash { font-family: monospace; color: var(--accent-info); font-weight: bold; }
11
+ .author { font-size: 0.85rem; color: var(--text-muted); }
12
+ .date { font-size: 0.85rem; color: var(--text-muted); }
13
+ .status-card { border-radius: 12px; border: 1px solid var(--border-subtle); margin-bottom: 20px; }
14
+ .nav-pills .nav-link { color: var(--text-muted); }
15
+ .nav-pills .nav-link.active { background-color: var(--accent-primary); color: white; }
16
+ </style>
17
+ {% endblock %}
18
+
19
+ {% block content %}
20
+ <div class="container py-4">
21
+ <div class="d-flex justify-content-between align-items-center mb-4">
22
+ <h2><i class="bi bi-gear-wide-connected me-2"></i>System Management</h2>
23
+ <div class="d-flex gap-2">
24
+ <button id="btn-backup-now" class="btn btn-outline-info">
25
+ <i class="bi bi-cloud-upload me-1"></i> Backup Data Now
26
+ </button>
27
+ <a href="{{ url_for('dashboard.dashboard') }}" class="btn btn-secondary">
28
+ <i class="bi bi-arrow-left me-1"></i> Back to Dashboard
29
+ </a>
30
+ </div>
31
+ </div>
32
+
33
+ <!-- Status Section -->
34
+ <div class="card status-card bg-dark text-white shadow-sm">
35
+ <div class="card-body">
36
+ <div class="row">
37
+ <div class="col-md-6 border-end border-secondary">
38
+ <h6 class="text-info mb-3"><i class="bi bi-code-slash me-2"></i>Code Status</h6>
39
+ <p class="mb-1 small">Branch: <span class="badge bg-primary">{{ current_branch }}</span></p>
40
+ <p class="mb-0 small">Active Commit: <span class="hash">{{ current_hash }}</span></p>
41
+
42
+ {% if uncommitted_changes %}
43
+ <div class="text-warning mt-2 small">
44
+ <i class="bi bi-exclamation-triangle-fill me-1"></i>
45
+ Uncommitted code changes detected.
46
+ </div>
47
+ {% endif %}
48
+ </div>
49
+ <div class="col-md-6 ps-md-4">
50
+ <h6 class="text-success mb-3"><i class="bi bi-database me-2"></i>Data Status (Hugging Face)</h6>
51
+ <p class="mb-1 small">Repo: <code class="text-light">{{ hf_repo_id }}</code></p>
52
+ <div id="backup-info" class="small text-muted">
53
+ Auto-backup syncs every 2 minutes.
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- Navigation Tabs -->
61
+ <ul class="nav nav-pills mb-3" id="manageTabs" role="tablist">
62
+ <li class="nav-item" role="presentation">
63
+ <button class="nav-link active" id="code-tab" data-bs-toggle="pill" data-bs-target="#code-pane" type="button" role="tab">
64
+ <i class="bi bi-git me-1"></i> Code Updates
65
+ </button>
66
+ </li>
67
+ <li class="nav-item" role="presentation">
68
+ <button class="nav-link" id="data-tab" data-bs-toggle="pill" data-bs-target="#data-pane" type="button" role="tab">
69
+ <i class="bi bi-cloud-check me-1"></i> Data Backups
70
+ </button>
71
+ </li>
72
+ </ul>
73
+
74
+ <div class="tab-content" id="manageTabsContent">
75
+ <!-- Code Tab -->
76
+ <div class="tab-pane fade show active" id="code-pane" role="tabpanel">
77
+ <div class="card bg-dark text-white border-secondary">
78
+ <div class="table-responsive">
79
+ <table class="table table-dark table-hover mb-0">
80
+ <thead>
81
+ <tr>
82
+ <th>Hash</th>
83
+ <th>Message</th>
84
+ <th>Author</th>
85
+ <th>When</th>
86
+ <th>Action</th>
87
+ </tr>
88
+ </thead>
89
+ <tbody>
90
+ {% for commit in code_commits %}
91
+ <tr class="commit-row {{ 'current' if commit.hash == current_hash else '' }}">
92
+ <td><span class="hash">{{ commit.hash }}</span></td>
93
+ <td>{{ commit.message }}</td>
94
+ <td><span class="author">{{ commit.author }}</span></td>
95
+ <td><span class="date">{{ commit.date }}</span></td>
96
+ <td>
97
+ {% if commit.hash != current_hash %}
98
+ <button class="btn btn-sm btn-outline-warning btn-switch-code" data-hash="{{ commit.hash }}">
99
+ Switch
100
+ </button>
101
+ {% else %}
102
+ <span class="badge bg-success">Active</span>
103
+ {% endif %}
104
+ </td>
105
+ </tr>
106
+ {% endfor %}
107
+ </tbody>
108
+ </table>
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ <!-- Data Tab -->
114
+ <div class="tab-pane fade" id="data-pane" role="tabpanel">
115
+ <div class="card bg-dark text-white border-secondary">
116
+ <div class="table-responsive">
117
+ <table class="table table-dark table-hover mb-0">
118
+ <thead>
119
+ <tr>
120
+ <th>HF Hash</th>
121
+ <th>Description</th>
122
+ <th>Timestamp</th>
123
+ <th>Action</th>
124
+ </tr>
125
+ </thead>
126
+ <tbody>
127
+ {% for commit in hf_commits %}
128
+ <tr class="commit-row">
129
+ <td><span class="hash text-success">{{ commit.hash[:8] }}</span></td>
130
+ <td>{{ commit.message }}</td>
131
+ <td><span class="date">{{ commit.date }}</span></td>
132
+ <td>
133
+ <button class="btn btn-sm btn-outline-danger btn-restore-data" data-hash="{{ commit.hash }}">
134
+ Restore
135
+ </button>
136
+ </td>
137
+ </tr>
138
+ {% endfor %}
139
+ {% if not hf_commits %}
140
+ <tr>
141
+ <td colspan="4" class="text-center py-4 text-muted">
142
+ No Hugging Face backups found or HF_TOKEN not set.
143
+ </td>
144
+ </tr>
145
+ {% endif %}
146
+ </tbody>
147
+ </table>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+
154
+ <!-- Modal: Switch Code -->
155
+ <div class="modal fade" id="switchCodeModal" tabindex="-1">
156
+ <div class="modal-dialog modal-dialog-centered">
157
+ <div class="modal-content bg-dark text-white border-warning">
158
+ <div class="modal-header border-secondary">
159
+ <h5 class="modal-title text-warning">Switch Application Version</h5>
160
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
161
+ </div>
162
+ <div class="modal-body">
163
+ <p>Switching code to commit <span id="target-code-hash" class="hash"></span>.</p>
164
+ <div class="alert alert-info py-2 small">
165
+ <i class="bi bi-shield-check me-2"></i>
166
+ <strong>Safety:</strong> We'll backup your data to HF and stash any uncommitted code before switching.
167
+ </div>
168
+ </div>
169
+ <div class="modal-footer border-secondary">
170
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
171
+ <form action="{{ url_for('main.manage_checkout') }}" method="POST">
172
+ <input type="hidden" name="hash" id="form-code-hash">
173
+ <button type="submit" class="btn btn-warning">Backup & Switch Code</button>
174
+ </form>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
+ <!-- Modal: Restore Data -->
181
+ <div class="modal fade" id="restoreDataModal" tabindex="-1">
182
+ <div class="modal-dialog modal-dialog-centered">
183
+ <div class="modal-content bg-dark text-white border-danger">
184
+ <div class="modal-header border-secondary">
185
+ <h5 class="modal-title text-danger">Restore Database & Images</h5>
186
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
187
+ </div>
188
+ <div class="modal-body">
189
+ <p>Restoring data from HF snapshot <span id="target-data-hash" class="hash"></span>.</p>
190
+ <div class="alert alert-danger py-2 small">
191
+ <i class="bi bi-exclamation-octagon-fill me-2"></i>
192
+ <strong>Warning:</strong> This will replace your current database and images with this older version.
193
+ </div>
194
+ <div class="alert alert-success py-2 small">
195
+ <i class="bi bi-shield-lock me-2"></i>
196
+ <strong>Protection:</strong> We'll create a safety backup of your CURRENT state before restoring.
197
+ </div>
198
+ </div>
199
+ <div class="modal-footer border-secondary">
200
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
201
+ <form action="{{ url_for('main.manage_restore') }}" method="POST">
202
+ <input type="hidden" name="hash" id="form-data-hash">
203
+ <button type="submit" class="btn btn-danger">Backup Current & Restore Old</button>
204
+ </form>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ {% endblock %}
210
+
211
+ {% block scripts %}
212
+ <script>
213
+ $(document).ready(function() {
214
+ const switchCodeModal = new bootstrap.Modal($('#switchCodeModal'));
215
+ const restoreDataModal = new bootstrap.Modal($('#restoreDataModal'));
216
+
217
+ $('.btn-switch-code').on('click', function() {
218
+ const hash = $(this).data('hash');
219
+ $('#target-code-hash').text(hash);
220
+ $('#form-code-hash').val(hash);
221
+ switchCodeModal.show();
222
+ });
223
+
224
+ $('.btn-restore-data').on('click', function() {
225
+ const hash = $(this).data('hash');
226
+ $('#target-data-hash').text(hash.substring(0, 8));
227
+ $('#form-data-hash').val(hash);
228
+ restoreDataModal.show();
229
+ });
230
+
231
+ $('#btn-backup-now').on('click', function() {
232
+ const btn = $(this);
233
+ const originalHtml = btn.html();
234
+ btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Uploading...');
235
+
236
+ $.ajax({
237
+ url: '/manage/backup',
238
+ type: 'POST',
239
+ success: function() {
240
+ btn.html('<i class="bi bi-check-lg me-1"></i> Success');
241
+ setTimeout(() => { btn.prop('disabled', false).html(originalHtml); }, 3000);
242
+ },
243
+ error: function() {
244
+ btn.html('<i class="bi bi-x-lg me-1"></i> Failed');
245
+ btn.addClass('btn-outline-danger').removeClass('btn-outline-info');
246
+ setTimeout(() => { btn.prop('disabled', false).html(originalHtml).removeClass('btn-outline-danger').addClass('btn-outline-info'); }, 3000);
247
+ }
248
+ });
249
+ });
250
+ });
251
+ </script>
252
+ {% endblock %}