mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-04-25 05:59:15 -05:00
Merge pull request #31 from DRYTRIX/feat-CorrectBackupWorker
feat(backup): add robust backup/restore with migration-aware restores…
This commit is contained in:
@@ -33,6 +33,13 @@ RUN apt-get update && apt-get install -y \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PostgreSQL 16 client tools (pg_dump/pg_restore) from PGDG to match server 16.x
|
||||
RUN apt-get update && apt-get install -y gnupg wget lsb-release && \
|
||||
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
|
||||
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
|
||||
apt-get update && apt-get install -y postgresql-client-16 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+77
-7
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import User, Project, TimeEntry, Settings
|
||||
@@ -8,9 +8,15 @@ import os
|
||||
from werkzeug.utils import secure_filename
|
||||
import uuid
|
||||
from app.utils.db import safe_commit
|
||||
from app.utils.backup import create_backup, restore_backup
|
||||
import threading
|
||||
import time
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
# In-memory restore progress tracking (simple, per-process)
|
||||
RESTORE_PROGRESS = {}
|
||||
|
||||
# Allowed file extensions for logos
|
||||
ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
|
||||
|
||||
@@ -313,15 +319,79 @@ def remove_logo():
|
||||
|
||||
return redirect(url_for('admin.settings'))
|
||||
|
||||
@admin_bp.route('/admin/backup')
|
||||
@admin_bp.route('/admin/backup', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def backup():
|
||||
"""Create manual backup"""
|
||||
# This would typically trigger a backup process
|
||||
# For now, just show a success message
|
||||
flash('Backup process initiated', 'success')
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
"""Create manual backup and return the archive for download."""
|
||||
try:
|
||||
archive_path = create_backup(current_app)
|
||||
if not archive_path or not os.path.exists(archive_path):
|
||||
flash('Backup failed: archive not created', 'error')
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
# Stream file to user
|
||||
return send_file(archive_path, as_attachment=True)
|
||||
except Exception as e:
|
||||
flash(f'Backup failed: {e}', 'error')
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
@admin_bp.route('/admin/restore', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def restore():
|
||||
"""Restore from an uploaded backup archive."""
|
||||
if request.method == 'POST':
|
||||
if 'backup_file' not in request.files or request.files['backup_file'].filename == '':
|
||||
flash('No backup file uploaded', 'error')
|
||||
return redirect(url_for('admin.restore'))
|
||||
file = request.files['backup_file']
|
||||
filename = secure_filename(file.filename)
|
||||
if not filename.lower().endswith('.zip'):
|
||||
flash('Invalid file type. Please upload a .zip backup archive.', 'error')
|
||||
return redirect(url_for('admin.restore'))
|
||||
# Save temporarily under project backups
|
||||
backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups')
|
||||
os.makedirs(backups_dir, exist_ok=True)
|
||||
temp_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{filename}")
|
||||
file.save(temp_path)
|
||||
|
||||
# Initialize progress state
|
||||
token = uuid.uuid4().hex[:8]
|
||||
RESTORE_PROGRESS[token] = {'status': 'starting', 'percent': 0, 'message': 'Queued'}
|
||||
|
||||
def progress_cb(label, percent):
|
||||
RESTORE_PROGRESS[token] = {'status': 'running', 'percent': int(percent), 'message': label}
|
||||
|
||||
# Capture the real Flask app object for use in a background thread
|
||||
app_obj = current_app._get_current_object()
|
||||
|
||||
def _do_restore():
|
||||
try:
|
||||
RESTORE_PROGRESS[token] = {'status': 'running', 'percent': 5, 'message': 'Starting restore'}
|
||||
success, message = restore_backup(app_obj, temp_path, progress_callback=progress_cb)
|
||||
RESTORE_PROGRESS[token] = {
|
||||
'status': 'done' if success else 'error',
|
||||
'percent': 100 if success else RESTORE_PROGRESS[token].get('percent', 0),
|
||||
'message': message
|
||||
}
|
||||
except Exception as e:
|
||||
RESTORE_PROGRESS[token] = {'status': 'error', 'percent': RESTORE_PROGRESS[token].get('percent', 0), 'message': str(e)}
|
||||
finally:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Run restore in background to keep request responsive
|
||||
t = threading.Thread(target=_do_restore, daemon=True)
|
||||
t.start()
|
||||
|
||||
flash('Restore started. You can monitor progress on this page.', 'info')
|
||||
return redirect(url_for('admin.restore', token=token))
|
||||
# GET
|
||||
token = request.args.get('token')
|
||||
progress = RESTORE_PROGRESS.get(token) if token else None
|
||||
return render_template('admin/restore.html', progress=progress, token=token)
|
||||
|
||||
@admin_bp.route('/admin/system')
|
||||
@login_required
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import os
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _get_backup_root_dir(app):
|
||||
"""Compute the absolute backups directory path (project_root/backups)."""
|
||||
project_root = os.path.abspath(os.path.join(app.root_path, '..'))
|
||||
backups_dir = os.path.join(project_root, 'backups')
|
||||
os.makedirs(backups_dir, exist_ok=True)
|
||||
return backups_dir
|
||||
|
||||
|
||||
def _now_timestamp():
|
||||
"""Return a human-readable local timestamp for file names."""
|
||||
# Respect user's preference to use local time across the project
|
||||
# rather than UTC for user-facing timestamps.
|
||||
return datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
|
||||
def _detect_db_type_and_path(app):
|
||||
"""Detect database type and return a tuple (type, uri, sqlite_path).
|
||||
|
||||
type: 'sqlite' or 'postgresql'
|
||||
uri: full SQLAlchemy database URI
|
||||
sqlite_path: file path if sqlite, otherwise None
|
||||
"""
|
||||
uri = app.config.get('SQLALCHEMY_DATABASE_URI', '') or ''
|
||||
if isinstance(uri, str) and uri.startswith('sqlite:///'):
|
||||
return 'sqlite', uri, uri.replace('sqlite:///', '')
|
||||
if isinstance(uri, str) and (uri.startswith('postgresql') or uri.startswith('postgres')):
|
||||
return 'postgresql', uri, None
|
||||
# Default/fallback
|
||||
return 'unknown', uri, None
|
||||
|
||||
|
||||
def _get_alembic_revision(db_session):
|
||||
"""Return current alembic revision string or None if unavailable."""
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
result = db_session.execute(text('SELECT version_num FROM alembic_version'))
|
||||
row = result.first()
|
||||
return row[0] if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _write_manifest(zf, manifest: dict):
|
||||
data = json.dumps(manifest, indent=2, sort_keys=True).encode('utf-8')
|
||||
zf.writestr('manifest.json', data)
|
||||
|
||||
|
||||
def _add_directory_to_zip(zf, source_dir: str, arc_prefix: str):
|
||||
if not source_dir or not os.path.isdir(source_dir):
|
||||
return
|
||||
for root, _, files in os.walk(source_dir):
|
||||
for file_name in files:
|
||||
abs_path = os.path.join(root, file_name)
|
||||
rel_path = os.path.relpath(abs_path, start=source_dir)
|
||||
zf.write(abs_path, os.path.join(arc_prefix, rel_path))
|
||||
|
||||
|
||||
def create_backup(app) -> str:
|
||||
"""Create a comprehensive backup archive and return its absolute path.
|
||||
|
||||
Contents:
|
||||
- manifest.json (metadata: created_at, db_type, alembic_revision)
|
||||
- db.sqlite (if sqlite) OR db.dump (if postgresql, custom format)
|
||||
- settings.json (serialized Settings row for quick inspection)
|
||||
- uploads/ (logos and other uploaded assets if present)
|
||||
"""
|
||||
# Late imports to avoid circular dependencies
|
||||
from app import db
|
||||
from app.models.settings import Settings
|
||||
|
||||
backups_dir = _get_backup_root_dir(app)
|
||||
timestamp = _now_timestamp()
|
||||
archive_name = f"timetracker_backup_{timestamp}.zip"
|
||||
archive_path = os.path.join(backups_dir, archive_name)
|
||||
|
||||
db_type, db_uri, sqlite_path = _detect_db_type_and_path(app)
|
||||
|
||||
# Prepare temporary directory for DB dumps if needed
|
||||
tmp_dir = tempfile.mkdtemp(prefix='tt_backup_')
|
||||
tmp_db_artifact = None
|
||||
|
||||
try:
|
||||
# Create DB artifact
|
||||
if db_type == 'sqlite' and sqlite_path and os.path.exists(sqlite_path):
|
||||
tmp_db_artifact = os.path.join(tmp_dir, 'db.sqlite')
|
||||
shutil.copy2(sqlite_path, tmp_db_artifact)
|
||||
elif db_type == 'postgresql':
|
||||
# Use parsed connection parameters (avoid SQLAlchemy driver suffix in URI)
|
||||
database_url = os.getenv('DATABASE_URL', db_uri)
|
||||
parsed = urlparse(database_url) if database_url else None
|
||||
host = (parsed.hostname if parsed and parsed.hostname else os.getenv('POSTGRES_HOST', 'db'))
|
||||
port = (parsed.port if parsed and parsed.port else int(os.getenv('POSTGRES_PORT', '5432')))
|
||||
user = (parsed.username if parsed and parsed.username else os.getenv('POSTGRES_USER', 'timetracker'))
|
||||
password = (parsed.password if parsed and parsed.password else os.getenv('POSTGRES_PASSWORD', 'timetracker'))
|
||||
dbname = (parsed.path.lstrip('/') if parsed and parsed.path else os.getenv('POSTGRES_DB', 'timetracker'))
|
||||
|
||||
tmp_db_artifact = os.path.join(tmp_dir, 'db.dump')
|
||||
pg_dump_cmd = [
|
||||
'pg_dump',
|
||||
'--format=custom',
|
||||
'-h', host,
|
||||
'-p', str(port),
|
||||
'-U', user,
|
||||
'-d', dbname,
|
||||
f'--file={tmp_db_artifact}',
|
||||
]
|
||||
env = os.environ.copy()
|
||||
if password:
|
||||
env['PGPASSWORD'] = str(password)
|
||||
try:
|
||||
completed = subprocess.run(pg_dump_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError('pg_dump not found. Please ensure PostgreSQL client tools are installed.')
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr = e.stderr.decode('utf-8', errors='ignore') if e.stderr else ''
|
||||
raise RuntimeError(f'pg_dump failed: {stderr.strip() or e}')
|
||||
else:
|
||||
# Best effort: we continue without DB artifact
|
||||
tmp_db_artifact = None
|
||||
|
||||
# Gather metadata
|
||||
alembic_rev = _get_alembic_revision(db.session)
|
||||
manifest = {
|
||||
'created_at': datetime.now().isoformat(timespec='seconds'),
|
||||
'db_type': db_type,
|
||||
'alembic_revision': alembic_rev,
|
||||
'app_version': None,
|
||||
}
|
||||
|
||||
# Serialize settings for convenience (DB backup still authoritative)
|
||||
settings_obj = Settings.get_settings()
|
||||
settings_json = json.dumps(settings_obj.to_dict(), indent=2, sort_keys=True)
|
||||
|
||||
# Write the zip
|
||||
with ZipFile(archive_path, mode='w', compression=ZIP_DEFLATED) as zf:
|
||||
_write_manifest(zf, manifest)
|
||||
if tmp_db_artifact and os.path.exists(tmp_db_artifact):
|
||||
arc_name = 'db.sqlite' if db_type == 'sqlite' else 'db.dump'
|
||||
zf.write(tmp_db_artifact, arc_name)
|
||||
|
||||
zf.writestr('settings.json', settings_json.encode('utf-8'))
|
||||
|
||||
# Include uploads (e.g., logos)
|
||||
uploads_root = os.path.join(app.root_path, 'static', 'uploads')
|
||||
_add_directory_to_zip(zf, uploads_root, 'uploads')
|
||||
|
||||
return archive_path
|
||||
finally:
|
||||
try:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def restore_backup(app, archive_path: str, progress_callback=None) -> tuple[bool, str]:
|
||||
"""Restore a backup archive.
|
||||
|
||||
Steps:
|
||||
- Extract archive to temp dir
|
||||
- Restore DB depending on type
|
||||
- Copy uploads back
|
||||
- Run migrations to head to ensure compatibility with newer code
|
||||
|
||||
Returns: (success, message)
|
||||
"""
|
||||
from app import db
|
||||
from time import sleep
|
||||
|
||||
if not archive_path or not os.path.exists(archive_path):
|
||||
return False, f"Backup archive not found: {archive_path}"
|
||||
|
||||
db_type, db_uri, sqlite_path = _detect_db_type_and_path(app)
|
||||
tmp_dir = tempfile.mkdtemp(prefix='tt_restore_')
|
||||
|
||||
def _progress(label: str, percent: int):
|
||||
try:
|
||||
if callable(progress_callback):
|
||||
progress_callback(label, percent)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Extract archive
|
||||
with ZipFile(archive_path, mode='r') as zf:
|
||||
zf.extractall(tmp_dir)
|
||||
_progress('Archive extracted', 10)
|
||||
|
||||
# Read manifest (optional)
|
||||
manifest_path = os.path.join(tmp_dir, 'manifest.json')
|
||||
if os.path.exists(manifest_path):
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
_ = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Proactively close any open DB connections before modifying files
|
||||
try:
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Restore DB
|
||||
if db_type == 'sqlite':
|
||||
src_sqlite = os.path.join(tmp_dir, 'db.sqlite')
|
||||
if not os.path.exists(src_sqlite):
|
||||
return False, 'Backup does not contain db.sqlite for SQLite restore'
|
||||
|
||||
if not sqlite_path:
|
||||
return False, 'Current configuration is not SQLite or path not found'
|
||||
|
||||
# Ensure destination directory exists
|
||||
dest_dir = os.path.dirname(sqlite_path)
|
||||
if dest_dir and not os.path.exists(dest_dir):
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
# Safety copy of current DB if exists
|
||||
if os.path.exists(sqlite_path):
|
||||
safety_copy = sqlite_path + f'.bak_{_now_timestamp()}'
|
||||
shutil.copy2(sqlite_path, safety_copy)
|
||||
|
||||
# Replace DB file
|
||||
os.makedirs(os.path.dirname(sqlite_path), exist_ok=True)
|
||||
# Retry a few times in case the file is briefly locked
|
||||
last_err = None
|
||||
for _ in range(3):
|
||||
try:
|
||||
shutil.copy2(src_sqlite, sqlite_path)
|
||||
last_err = None
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
sleep(0.2)
|
||||
if last_err:
|
||||
return False, f'Failed to write SQLite database file: {last_err}'
|
||||
_progress('SQLite database restored', 60)
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
src_dump = os.path.join(tmp_dir, 'db.dump')
|
||||
if not os.path.exists(src_dump):
|
||||
return False, 'Backup does not contain db.dump for PostgreSQL restore'
|
||||
|
||||
database_url = os.getenv('DATABASE_URL', db_uri)
|
||||
parsed = urlparse(database_url) if database_url else None
|
||||
host = (parsed.hostname if parsed and parsed.hostname else os.getenv('POSTGRES_HOST', 'db'))
|
||||
port = (parsed.port if parsed and parsed.port else int(os.getenv('POSTGRES_PORT', '5432')))
|
||||
user = (parsed.username if parsed and parsed.username else os.getenv('POSTGRES_USER', 'timetracker'))
|
||||
password = (parsed.password if parsed and parsed.password else os.getenv('POSTGRES_PASSWORD', 'timetracker'))
|
||||
dbname = (parsed.path.lstrip('/') if parsed and parsed.path else os.getenv('POSTGRES_DB', 'timetracker'))
|
||||
|
||||
pg_restore_cmd = [
|
||||
'pg_restore',
|
||||
'--clean',
|
||||
'--if-exists',
|
||||
'--no-owner',
|
||||
'-h', host,
|
||||
'-p', str(port),
|
||||
'-U', user,
|
||||
'-d', dbname,
|
||||
src_dump,
|
||||
]
|
||||
env = os.environ.copy()
|
||||
if password:
|
||||
env['PGPASSWORD'] = str(password)
|
||||
try:
|
||||
_progress('Restoring PostgreSQL database', 20)
|
||||
subprocess.run(pg_restore_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
except FileNotFoundError:
|
||||
return False, 'pg_restore not found. Please install PostgreSQL client tools.'
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr = e.stderr.decode('utf-8', errors='ignore') if e.stderr else ''
|
||||
return False, f'pg_restore failed: {stderr.strip() or e}'
|
||||
_progress('PostgreSQL database restored', 60)
|
||||
else:
|
||||
return False, 'Unsupported or unknown database type for restore'
|
||||
|
||||
# Restore uploads
|
||||
extracted_uploads = os.path.join(tmp_dir, 'uploads')
|
||||
if os.path.isdir(extracted_uploads):
|
||||
target_uploads = os.path.join(app.root_path, 'static', 'uploads')
|
||||
os.makedirs(target_uploads, exist_ok=True)
|
||||
# Merge copy
|
||||
for root, _, files in os.walk(extracted_uploads):
|
||||
rel = os.path.relpath(root, extracted_uploads)
|
||||
target_dir = os.path.join(target_uploads, rel) if rel != '.' else target_uploads
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
for fn in files:
|
||||
shutil.copy2(os.path.join(root, fn), os.path.join(target_dir, fn))
|
||||
_progress('Uploads restored', 80)
|
||||
|
||||
# Run migrations to ensure compatibility with current code
|
||||
try:
|
||||
from flask_migrate import upgrade as alembic_upgrade
|
||||
with app.app_context():
|
||||
_progress('Running migrations', 90)
|
||||
alembic_upgrade()
|
||||
except Exception as e:
|
||||
# If migrations fail, report failure to caller for visibility
|
||||
return False, f'Restore completed but migration failed: {e}'
|
||||
|
||||
# Dispose connections once more after restore/migrate to ensure clean state
|
||||
try:
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
db.engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_progress('Restore completed successfully', 100)
|
||||
return True, 'Restore completed successfully'
|
||||
finally:
|
||||
try:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from app import db
|
||||
from app.models import User, Project, TimeEntry, Settings, Client
|
||||
from datetime import datetime, timedelta
|
||||
import shutil
|
||||
from app.utils.backup import create_backup, restore_backup
|
||||
|
||||
def register_cli_commands(app):
|
||||
"""Register CLI commands for the application"""
|
||||
@@ -94,6 +95,36 @@ def register_cli_commands(app):
|
||||
except Exception as e:
|
||||
click.echo(f"Warning: Could not clean up old backups: {e}")
|
||||
|
||||
@app.cli.command()
|
||||
@with_appcontext
|
||||
def backup_create():
|
||||
"""Create a full backup archive (DB, settings, uploads)."""
|
||||
try:
|
||||
archive_path = create_backup(click.get_current_context().obj or app)
|
||||
if archive_path:
|
||||
click.echo(f"Backup created: {archive_path}")
|
||||
else:
|
||||
click.echo("Backup failed: no archive path returned")
|
||||
except Exception as e:
|
||||
click.echo(f"Backup failed: {e}")
|
||||
|
||||
@app.cli.command()
|
||||
@with_appcontext
|
||||
@click.argument('archive_path')
|
||||
def backup_restore(archive_path):
|
||||
"""Restore from a backup archive and run migrations."""
|
||||
if not archive_path:
|
||||
click.echo('Usage: flask backup_restore <path_to_backup_zip>')
|
||||
return
|
||||
try:
|
||||
success, message = restore_backup(click.get_current_context().obj or app, archive_path)
|
||||
click.echo(message)
|
||||
if not success:
|
||||
raise SystemExit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"Restore failed: {e}")
|
||||
raise SystemExit(1)
|
||||
|
||||
@app.cli.command()
|
||||
@with_appcontext
|
||||
def migrate_to_flask_migrate():
|
||||
|
||||
+223
-242
@@ -4,252 +4,233 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-cogs text-primary"></i> Admin Dashboard
|
||||
</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-info">
|
||||
<i class="fas fa-info-circle"></i> System Info
|
||||
</a>
|
||||
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-warning">
|
||||
<i class="fas fa-download"></i> Backup
|
||||
</a>
|
||||
<a href="{{ url_for('admin.license_status') }}" class="btn btn-outline-info">
|
||||
<i class="fas fa-key"></i> License Status
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card stats-card hover-lift mb-4">
|
||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center">
|
||||
<div class="mb-3 mb-md-0">
|
||||
<h1 class="h3 mb-1"><i class="fas fa-cogs me-2"></i>Admin Dashboard</h1>
|
||||
<p class="mb-0">Manage users, system settings, and core operations at a glance.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-info-circle"></i> System Info
|
||||
</a>
|
||||
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-download"></i> Create Backup
|
||||
</a>
|
||||
<a href="{{ url_for('admin.restore') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-undo-alt"></i> Restore
|
||||
</a>
|
||||
<a href="{{ url_for('admin.license_status') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-key"></i> License Status
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-users fa-2x text-primary mb-2"></i>
|
||||
<h4 class="text-primary">{{ stats.total_users }}</h4>
|
||||
<p class="text-muted mb-0">Total Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-project-diagram fa-2x text-success mb-2"></i>
|
||||
<h4 class="text-success">{{ stats.total_projects }}</h4>
|
||||
<p class="text-muted mb-0">Total Projects</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-clock fa-2x text-info mb-2"></i>
|
||||
<h4 class="text-info">{{ stats.total_entries }}</h4>
|
||||
<p class="text-muted mb-0">Time Entries</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-dollar-sign fa-2x text-warning mb-2"></i>
|
||||
<h4 class="text-warning">{{ "%.1f"|format(stats.total_hours) }}h</h4>
|
||||
<p class="text-muted mb-0">Total Hours</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- System Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card hover-lift">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="summary-icon bg-primary bg-opacity-10 text-primary">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="summary-label">Total Users</div>
|
||||
<div class="summary-value">{{ stats.total_users }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card hover-lift">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="summary-icon bg-success bg-opacity-10 text-success">
|
||||
<i class="fas fa-project-diagram"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="summary-label">Total Projects</div>
|
||||
<div class="summary-value">{{ stats.total_projects }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card hover-lift">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="summary-icon bg-info bg-opacity-10 text-info">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="summary-label">Time Entries</div>
|
||||
<div class="summary-value">{{ stats.total_entries }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 mb-3">
|
||||
<div class="card border-0 shadow-sm h-100 summary-card hover-lift">
|
||||
<div class="card-body p-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="summary-icon bg-warning bg-opacity-10 text-warning">
|
||||
<i class="fas fa-dollar-sign"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="summary-label">Total Hours</div>
|
||||
<div class="summary-value">{{ "%.1f"|format(stats.total_hours) }}h</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-user-cog"></i> User Management
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-users"></i> Manage Users
|
||||
</a>
|
||||
<a href="{{ url_for('admin.create_user') }}" class="btn btn-outline-success">
|
||||
<i class="fas fa-user-plus"></i> Create New User
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick Actions -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm border-0 hover-lift">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-user-cog me-2"></i>User Management
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin.list_users') }}" class="btn btn-soft-primary">
|
||||
<i class="fas fa-users"></i> Manage Users
|
||||
</a>
|
||||
<a href="{{ url_for('admin.create_user') }}" class="btn btn-soft-success">
|
||||
<i class="fas fa-user-plus"></i> Create New User
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-cog"></i> System Settings
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin.settings') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-sliders-h"></i> Configure Settings
|
||||
</a>
|
||||
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-warning">
|
||||
<i class="fas fa-download"></i> Create Backup
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm border-0 hover-lift">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-cog me-2"></i>System Settings
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('admin.settings') }}" class="btn btn-soft-secondary">
|
||||
<i class="fas fa-sliders-h"></i> Configure Settings
|
||||
</a>
|
||||
<a href="{{ url_for('admin.backup') }}" class="btn btn-soft-primary">
|
||||
<i class="fas fa-download"></i> Create Backup
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-history"></i> Recent Activity
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_entries %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Project</th>
|
||||
<th>Date</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.user.username }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
|
||||
{{ entry.project.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<strong>{{ entry.duration_formatted }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.end_time %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-clock fa-2x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No Recent Activity</h5>
|
||||
<p class="text-muted">No time entries have been recorded recently.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recent Activity -->
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm border-0 hover-lift">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-history me-2"></i>Recent Activity
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if recent_entries %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Project</th>
|
||||
<th>Date</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.user.username }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
|
||||
{{ entry.project.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<strong>{{ entry.duration_formatted }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.end_time %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Running</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-clock fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No recent activity</h5>
|
||||
<p class="text-muted mb-0">No time entries have been recorded recently.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-pie"></i> System Overview
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<h6>Active Users</h6>
|
||||
<div class="progress mb-2">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ (stats.active_users / stats.total_users * 100) if stats.total_users > 0 else 0 }}%">
|
||||
{{ stats.active_users }}/{{ stats.total_users }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6>Active Projects</h6>
|
||||
<div class="progress mb-2">
|
||||
<div class="progress-bar bg-success" role="progressbar"
|
||||
style="width: {{ (stats.active_projects / stats.total_projects * 100) if stats.total_projects > 0 else 0 }}%">
|
||||
{{ stats.active_projects }}/{{ stats.total_projects }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6>Billable Hours</h6>
|
||||
<div class="progress mb-2">
|
||||
<div class="progress-bar bg-warning" role="progressbar"
|
||||
style="width: {{ (stats.billable_hours / stats.total_hours * 100) if stats.total_hours > 0 else 0 }}%">
|
||||
{{ "%.1f"|format(stats.billable_hours) }}h/{{ "%.1f"|format(stats.total_hours) }}h
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h6>System Health</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>Database</span>
|
||||
<span class="badge bg-success">Healthy</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>Backup Status</span>
|
||||
{% if stats.last_backup %}
|
||||
<span class="badge bg-success">{{ stats.last_backup.strftime('%Y-%m-%d') }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">No Backup</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>License Server</span>
|
||||
<a href="{{ url_for('admin.license_status') }}" class="badge bg-info text-decoration-none">View Status</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-tools"></i> Quick Actions
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('projects.create_project') }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-plus"></i> New Project
|
||||
</a>
|
||||
<a href="{{ url_for('reports.reports') }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="fas fa-chart-line"></i> View Reports
|
||||
</a>
|
||||
<a href="{{ url_for('admin.system_info') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-info-circle"></i> System Info
|
||||
</a>
|
||||
<a href="{{ url_for('admin.license_status') }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="fas fa-key"></i> License Status
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm border-0 mt-3">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-tools me-2"></i>Quick Actions
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('projects.create_project') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-plus"></i> New Project
|
||||
</a>
|
||||
<a href="{{ url_for('reports.reports') }}" class="btn btn-outline-info">
|
||||
<i class="fas fa-chart-line"></i> View Reports
|
||||
</a>
|
||||
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-info-circle"></i> System Info
|
||||
</a>
|
||||
<a href="{{ url_for('admin.license_status') }}" class="btn btn-outline-info">
|
||||
<i class="fas fa-key"></i> License Status
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Restore Backup{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<h1 class="h3 mb-0 me-3">
|
||||
<i class="fas fa-undo-alt text-danger"></i>
|
||||
Restore Backup
|
||||
</h1>
|
||||
<span class="badge bg-danger fs-6">Danger Operation</span>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-file-archive me-2"></i>Upload Backup Archive (.zip)
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
Restoring a backup will overwrite your current database and files. Ensure you have a recent backup before proceeding.
|
||||
</div>
|
||||
{% if progress %}
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Status:</strong>
|
||||
<span class="badge {{ 'bg-success' if progress.status == 'done' else ('bg-danger' if progress.status == 'error' else 'bg-info') }}">
|
||||
{{ progress.status|title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress progress-thin mb-2">
|
||||
<div class="progress-bar" role="progressbar" style="width: {{ progress.percent }}%">
|
||||
{{ progress.percent }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted small">{{ progress.message }}</div>
|
||||
</div>
|
||||
<script>
|
||||
// Auto-refresh progress every 2s while running
|
||||
(function(){
|
||||
const status = "{{ progress.status }}";
|
||||
if (status === 'running') {
|
||||
setTimeout(function(){ window.location.href = "{{ url_for('admin.restore', token=token) }}"; }, 2000);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('admin.restore') }}" method="post" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="backup_file" class="form-label">Backup Archive (.zip)</label>
|
||||
<input class="form-control" type="file" id="backup_file" name="backup_file" accept=".zip" required>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Select a .zip archive previously created via the Backup action.
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-undo-alt me-1"></i> Restore
|
||||
</button>
|
||||
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-shield-alt me-2"></i>Safety Tips
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="text-muted mb-0">
|
||||
<li>Verify the backup archive integrity before restoring.</li>
|
||||
<li>Ensure no active writes are occurring during restore.</li>
|
||||
<li>Keep a copy of the current data in case you need to roll back.</li>
|
||||
<li>After restore, review settings and re-run migrations if required.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
+62
-28
@@ -7,16 +7,20 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<div class="d-flex align-items-start flex-column flex-md-row">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('admin.admin_dashboard') }}">Admin</a></li>
|
||||
<li class="breadcrumb-item active">Users</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="fas fa-users text-primary"></i> User Management
|
||||
</h1>
|
||||
<div class="d-flex align-items-center">
|
||||
<h1 class="h3 mb-0 me-3">
|
||||
<i class="fas fa-users text-primary"></i>
|
||||
User Management
|
||||
</h1>
|
||||
<span class="badge bg-primary fs-6">{{ users|length }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
|
||||
@@ -70,16 +74,26 @@
|
||||
<!-- Users List -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-list"></i> Users ({{ users|length }})
|
||||
</h5>
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-white py-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-list me-2"></i>All Users
|
||||
</h6>
|
||||
<div class="d-flex gap-2">
|
||||
<div class="input-group input-group-sm" style="width: 280px;">
|
||||
<span class="input-group-text bg-light border-end-0">
|
||||
<i class="fas fa-search text-muted"></i>
|
||||
</span>
|
||||
<input type="text" class="form-control border-start-0" id="searchInput" placeholder="Search users...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body p-0">
|
||||
{% if users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<table class="table table-hover mb-0" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
@@ -88,7 +102,7 @@
|
||||
<th>Created</th>
|
||||
<th>Last Login</th>
|
||||
<th>Total Hours</th>
|
||||
<th>Actions</th>
|
||||
<th class="text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -129,14 +143,14 @@
|
||||
<td>
|
||||
<strong>{{ "%.1f"|format(user.total_hours) }}h</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<td class="text-center actions-cell">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Edit">
|
||||
class="btn btn-sm btn-action btn-action--edit" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if user.id != current_user.id %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete"
|
||||
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="Delete"
|
||||
onclick="showDeleteUserModal('{{ user.id }}', '{{ user.username }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -150,12 +164,14 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">No Users Found</h4>
|
||||
<p class="text-muted">No users have been created yet.</p>
|
||||
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus"></i> Create First User
|
||||
</a>
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No users found</h5>
|
||||
<p class="text-muted mb-4">Create your first user to get started with administration.</p>
|
||||
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus me-2"></i> Create First User
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -204,13 +220,31 @@ function showDeleteUserModal(userId, username) {
|
||||
new bootstrap.Modal(document.getElementById('deleteUserModal')).show();
|
||||
}
|
||||
|
||||
// Add loading state to delete user form
|
||||
// Add loading state to delete user form and wire table search
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('deleteUserForm').addEventListener('submit', function(e) {
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
const deleteUserForm = document.getElementById('deleteUserForm');
|
||||
if (deleteUserForm) {
|
||||
deleteUserForm.addEventListener('submit', function() {
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
if (submitBtn) {
|
||||
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
|
||||
submitBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const table = document.getElementById('usersTable');
|
||||
if (searchInput && table) {
|
||||
const tbody = table.querySelector('tbody');
|
||||
searchInput.addEventListener('keyup', function() {
|
||||
const query = this.value.toLowerCase();
|
||||
Array.from(tbody.querySelectorAll('tr')).forEach(function(row) {
|
||||
const text = row.textContent.toLowerCase();
|
||||
row.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user