feat: admin panel overhaul - editor post selector, changelog/sitrep/datasync/cronjobs sections, backup import, grouped sidebar
This commit is contained in:
parent
3d01e1e173
commit
bb2aecd9b8
3 changed files with 890 additions and 19 deletions
206
admin.html
206
admin.html
|
|
@ -56,6 +56,7 @@
|
|||
<button class="sidebar-close" onclick="AdminApp.toggleSidebar()" aria-label="Close menu">✕</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-section-label">CONTENT</div>
|
||||
<a href="javascript:void(0)" class="sidebar-link active" data-section="dashboard" onclick="event.preventDefault(); AdminApp.showSection('dashboard')">
|
||||
<span class="sidebar-icon">◈</span> Dashboard
|
||||
</a>
|
||||
|
|
@ -65,16 +66,33 @@
|
|||
<a href="javascript:void(0)" class="sidebar-link" data-section="editor" onclick="event.preventDefault(); AdminApp.showSection('editor')">
|
||||
<span class="sidebar-icon">✎</span> Editor
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="changelog" onclick="event.preventDefault(); AdminApp.showSection('changelog')">
|
||||
<span class="sidebar-icon">📋</span> Changelog
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="tracks" onclick="event.preventDefault(); AdminApp.showSection('tracks')">
|
||||
<span class="sidebar-icon">♫</span> Tracks
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="settings" onclick="event.preventDefault(); AdminApp.showSection('settings')">
|
||||
<span class="sidebar-icon">⚙</span> Settings
|
||||
|
||||
<div class="sidebar-divider"></div>
|
||||
<div class="sidebar-section-label">AUTOMATION</div>
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="sitrep" onclick="event.preventDefault(); AdminApp.showSection('sitrep')">
|
||||
<span class="sidebar-icon">📡</span> SITREP
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="datasync" onclick="event.preventDefault(); AdminApp.showSection('datasync')">
|
||||
<span class="sidebar-icon">⟳</span> Data Sync
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="cronjobs" onclick="event.preventDefault(); AdminApp.showSection('cronjobs')">
|
||||
<span class="sidebar-icon">⏱</span> Cron Jobs
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="radar" onclick="event.preventDefault(); window.open('/transmissions/radar', '_blank')">
|
||||
<span class="sidebar-icon">📻</span> RADAR ↗
|
||||
</a>
|
||||
|
||||
<div class="sidebar-divider"></div>
|
||||
<div class="sidebar-section-label">CONTENT</div>
|
||||
|
||||
<div class="sidebar-section-label">SITE CONFIG</div>
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="settings" onclick="event.preventDefault(); AdminApp.showSection('settings')">
|
||||
<span class="sidebar-icon">⚙</span> Settings
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="homepage" onclick="event.preventDefault(); AdminApp.showSection('homepage')">
|
||||
<span class="sidebar-icon">⬡</span> Homepage
|
||||
</a>
|
||||
|
|
@ -91,10 +109,8 @@
|
|||
<span class="sidebar-icon">🤖</span> Chat AI
|
||||
</a>
|
||||
|
||||
|
||||
<div class="sidebar-divider"></div>
|
||||
<div class="sidebar-section-label">SYSTEM</div>
|
||||
|
||||
<a href="javascript:void(0)" class="sidebar-link" data-section="services" onclick="event.preventDefault(); AdminApp.showSection('services')">
|
||||
<span class="sidebar-icon">⟐</span> Services
|
||||
</a>
|
||||
|
|
@ -115,7 +131,7 @@
|
|||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<span class="sidebar-version">v2.0 // EXPANDED HUD</span>
|
||||
<span class="sidebar-version">v3.0 // FULL HUD</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -206,6 +222,15 @@
|
|||
<div id="section-editor" class="admin-section">
|
||||
<h2 class="section-heading">✎ POST EDITOR</h2>
|
||||
|
||||
<!-- Post Selector -->
|
||||
<div class="section-toolbar" style="margin-bottom:1.5rem;">
|
||||
<select id="editorPostSelect" class="editor-input" style="max-width:400px;" onchange="AdminApp.loadPostForEdit(this.value)">
|
||||
<option value="">— NEW POST —</option>
|
||||
</select>
|
||||
<button class="action-btn accent" onclick="AdminApp.clearEditor()">+ NEW POST</button>
|
||||
<button class="action-btn danger" id="editorDeleteBtn" onclick="AdminApp.deletePost(document.getElementById('editorPostId').value)" style="display:none;">✕ DELETE</button>
|
||||
</div>
|
||||
|
||||
<!-- Post Metadata -->
|
||||
<div class="editor-row">
|
||||
<div class="editor-field">
|
||||
|
|
@ -1008,10 +1033,9 @@
|
|||
<!-- 14. BACKUPS -->
|
||||
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
||||
<div id="section-backups" class="admin-section">
|
||||
<h2 class="section-heading">⛋ BACKUPS & EXPORT</h2>
|
||||
<h2 class="section-heading">⛋ BACKUPS & DATA</h2>
|
||||
|
||||
<div class="backup-grid">
|
||||
<!-- Individual Downloads -->
|
||||
<div class="backup-card">
|
||||
<div class="backup-card-icon">▤</div>
|
||||
<div class="backup-card-title">POSTS DATA</div>
|
||||
|
|
@ -1035,11 +1059,22 @@
|
|||
<!-- Export All -->
|
||||
<div class="backup-export">
|
||||
<h3 class="section-subheading">⛋ FULL EXPORT</h3>
|
||||
<p class="backup-desc">Download all site data (posts, tracks, settings) as a single ZIP archive.</p>
|
||||
<p class="backup-desc">Download all site data as a single ZIP archive.</p>
|
||||
<button class="action-btn accent lg" onclick="AdminApp.downloadBackup('all')">⬡ EXPORT ALL DATA (ZIP)</button>
|
||||
</div>
|
||||
|
||||
<!-- Backup Status -->
|
||||
<!-- Import -->
|
||||
<div class="backup-export" style="margin-top:2rem;">
|
||||
<h3 class="section-subheading">⬡ IMPORT / RESTORE</h3>
|
||||
<p class="backup-desc">Upload a previously exported ZIP to restore all JSON data files.</p>
|
||||
<div class="editor-row" style="margin-top:1rem;">
|
||||
<div class="editor-field">
|
||||
<input type="file" id="backupImportFile" class="editor-input" accept=".zip">
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-btn warning lg" onclick="AdminApp.importBackup()" style="margin-top:0.5rem;">⬡ IMPORT BACKUP (ZIP)</button>
|
||||
</div>
|
||||
|
||||
<div id="backupStatus" class="backup-status"></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1181,6 +1216,155 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
||||
<!-- 17. CHANGELOG -->
|
||||
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
||||
<div id="section-changelog" class="admin-section">
|
||||
<h2 class="section-heading">📋 CHANGELOG</h2>
|
||||
|
||||
<!-- Add Entry Form -->
|
||||
<div class="admin-card" style="margin-bottom:2rem;">
|
||||
<h3 class="section-subheading">+ ADD ENTRY</h3>
|
||||
<div class="editor-row">
|
||||
<div class="editor-field">
|
||||
<label class="editor-label" for="clVersion">VERSION</label>
|
||||
<input type="text" id="clVersion" class="editor-input" placeholder="v3.0.1">
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label class="editor-label" for="clDate">DATE</label>
|
||||
<input type="date" id="clDate" class="editor-input">
|
||||
</div>
|
||||
<div class="editor-field">
|
||||
<label class="editor-label" for="clType">TYPE</label>
|
||||
<select id="clType" class="editor-input">
|
||||
<option value="update">UPDATE</option>
|
||||
<option value="feature">FEATURE</option>
|
||||
<option value="fix">FIX</option>
|
||||
<option value="security">SECURITY</option>
|
||||
<option value="breaking">BREAKING</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-row">
|
||||
<div class="editor-field full">
|
||||
<label class="editor-label" for="clTitle">TITLE</label>
|
||||
<input type="text" id="clTitle" class="editor-input" placeholder="Entry title...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-row">
|
||||
<div class="editor-field full">
|
||||
<label class="editor-label" for="clDescription">DESCRIPTION</label>
|
||||
<textarea id="clDescription" class="editor-input editor-textarea-sm" placeholder="Brief description..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-row">
|
||||
<div class="editor-field full">
|
||||
<label class="editor-label" for="clChanges">CHANGES (one per line)</label>
|
||||
<textarea id="clChanges" class="editor-input editor-textarea-sm" placeholder="Added new feature X Fixed bug in Y Improved Z performance"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="clEditId" value="">
|
||||
<button class="action-btn accent" onclick="AdminApp.saveChangelog()">⬡ SAVE ENTRY</button>
|
||||
<button class="action-btn" onclick="AdminApp.clearChangelog()" style="margin-left:0.5rem;">✕ CLEAR</button>
|
||||
</div>
|
||||
|
||||
<!-- Entries List -->
|
||||
<div id="changelogList" class="changelog-admin-list">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
||||
<!-- 18. SITREP -->
|
||||
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
||||
<div id="section-sitrep" class="admin-section">
|
||||
<h2 class="section-heading">📡 SITREP — DAILY BRIEFING</h2>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="dash-grid" style="margin-bottom:2rem;">
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-label">TOTAL REPORTS</div>
|
||||
<div class="dash-card-value" id="sitrepTotal">--</div>
|
||||
</div>
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-label">LATEST DATE</div>
|
||||
<div class="dash-card-value" id="sitrepLatestDate">--</div>
|
||||
</div>
|
||||
<div class="dash-card">
|
||||
<div class="dash-card-label">STATUS</div>
|
||||
<div class="dash-card-value" id="sitrepStatus">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generate Button -->
|
||||
<div class="section-toolbar">
|
||||
<button class="action-btn warning lg" onclick="AdminApp.generateSitrep()" id="sitrepGenerateBtn">⚡ GENERATE NOW</button>
|
||||
</div>
|
||||
|
||||
<!-- Today's SITREP Summary -->
|
||||
<div id="sitrepTodaySummary" class="admin-card" style="margin-top:1.5rem; display:none;">
|
||||
<h3 class="section-subheading">⬡ TODAY'S SITREP</h3>
|
||||
<div id="sitrepHeadline" class="sitrep-headline"></div>
|
||||
<div id="sitrepSummaryText" class="sitrep-summary-text"></div>
|
||||
</div>
|
||||
|
||||
<!-- Archive List -->
|
||||
<h3 class="section-subheading" style="margin-top:2rem;">⛋ ARCHIVE</h3>
|
||||
<div id="sitrepArchive" class="sitrep-archive-list">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
||||
<!-- 19. DATA SYNC -->
|
||||
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
||||
<div id="section-datasync" class="admin-section">
|
||||
<h2 class="section-heading">⟳ DATA SYNC</h2>
|
||||
|
||||
<div class="sync-grid">
|
||||
<!-- Contraband Card -->
|
||||
<div class="admin-card sync-card">
|
||||
<h3 class="section-subheading">🚫 CONTRABAND (FMHY)</h3>
|
||||
<div class="sync-stats">
|
||||
<div class="sync-stat"><span class="sync-stat-label">ENTRIES:</span> <span id="syncCbEntries">--</span></div>
|
||||
<div class="sync-stat"><span class="sync-stat-label">CATEGORIES:</span> <span id="syncCbCategories">--</span></div>
|
||||
<div class="sync-stat"><span class="sync-stat-label">STARRED:</span> <span id="syncCbStarred">--</span></div>
|
||||
<div class="sync-stat"><span class="sync-stat-label">LAST SYNC:</span> <span id="syncCbLastSync">--</span></div>
|
||||
</div>
|
||||
<button class="action-btn warning" onclick="AdminApp.runSync('contraband')" id="syncCbBtn">⚡ SYNC NOW</button>
|
||||
</div>
|
||||
|
||||
<!-- RECON Card -->
|
||||
<div class="admin-card sync-card">
|
||||
<h3 class="section-subheading">🔍 RECON (AWESOMELIST)</h3>
|
||||
<div class="sync-stats">
|
||||
<div class="sync-stat"><span class="sync-stat-label">SECTORS:</span> <span id="syncAlSectors">--</span></div>
|
||||
<div class="sync-stat"><span class="sync-stat-label">LISTS:</span> <span id="syncAlLists">--</span></div>
|
||||
<div class="sync-stat"><span class="sync-stat-label">ENTRIES:</span> <span id="syncAlEntries">--</span></div>
|
||||
<div class="sync-stat"><span class="sync-stat-label">LAST SYNC:</span> <span id="syncAlLastSync">--</span></div>
|
||||
</div>
|
||||
<button class="action-btn warning" onclick="AdminApp.runSync('awesomelist')" id="syncAlBtn">⚡ SYNC NOW</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="syncLog" class="sync-log" style="margin-top:1.5rem; display:none;">
|
||||
<h3 class="section-subheading">📋 SYNC LOG</h3>
|
||||
<pre id="syncLogContent" class="sync-log-content"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
||||
<!-- 20. CRON JOBS -->
|
||||
<!-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -->
|
||||
<div id="section-cronjobs" class="admin-section">
|
||||
<h2 class="section-heading">⏱ CRON JOBS</h2>
|
||||
|
||||
<div id="cronJobsList" class="cron-jobs-list">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /main-content -->
|
||||
</div><!-- /adminApp -->
|
||||
|
||||
|
|
|
|||
248
api/app.py
248
api/app.py
|
|
@ -24,7 +24,7 @@ ADMIN_PASS = 'HUDAdmin2026!'
|
|||
|
||||
ARRAY_FILES = {
|
||||
'posts.json', 'tracks.json', 'navigation.json', 'links.json',
|
||||
'managed_services.json', 'messages.json'
|
||||
'managed_services.json', 'messages.json', 'changelog.json'
|
||||
}
|
||||
# ─── JSON Error Handlers ─────────────────────────────
|
||||
@app.errorhandler(400)
|
||||
|
|
@ -898,14 +898,252 @@ def contraband_search():
|
|||
# ─── Run ─────────────────────────────────────────────
|
||||
|
||||
|
||||
# ─── Changelog ────────────────────────────────────────
|
||||
# ─── Changelog CRUD ───────────────────────────────────
|
||||
@app.route('/api/changelog')
|
||||
def get_changelog():
|
||||
return jsonify(load_json('changelog.json'))
|
||||
|
||||
@app.route('/api/changelog', methods=['POST'])
|
||||
@require_auth
|
||||
def add_changelog_entry():
|
||||
try:
|
||||
with open(os.path.join(DATA_DIR, 'changelog.json'), 'r') as f:
|
||||
return json.load(f)
|
||||
d = request.get_json(force=True)
|
||||
entries = load_json('changelog.json')
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
entry = {
|
||||
'id': max((e.get('id', 0) for e in entries), default=0) + 1,
|
||||
'version': d.get('version', ''),
|
||||
'date': d.get('date', datetime.date.today().isoformat()),
|
||||
'title': d.get('title', ''),
|
||||
'description': d.get('description', ''),
|
||||
'changes': d.get('changes', []),
|
||||
'type': d.get('type', 'update')
|
||||
}
|
||||
entries.insert(0, entry)
|
||||
save_json('changelog.json', entries)
|
||||
return jsonify(entry), 201
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/changelog/<int:entry_id>', methods=['PUT'])
|
||||
@require_auth
|
||||
def update_changelog_entry(entry_id):
|
||||
try:
|
||||
d = request.get_json(force=True)
|
||||
entries = load_json('changelog.json')
|
||||
for i, e in enumerate(entries):
|
||||
if e.get('id') == entry_id:
|
||||
entries[i] = {**e, **d, 'id': entry_id}
|
||||
save_json('changelog.json', entries)
|
||||
return jsonify(entries[i])
|
||||
abort(404, 'Changelog entry not found')
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/changelog/<int:entry_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_changelog_entry(entry_id):
|
||||
entries = load_json('changelog.json')
|
||||
entries = [e for e in entries if e.get('id') != entry_id]
|
||||
save_json('changelog.json', entries)
|
||||
return jsonify({'ok': True})
|
||||
|
||||
# ─── Data Sync (Contraband + Awesomelist) ─────────────
|
||||
@app.route('/api/sync/status')
|
||||
@require_auth
|
||||
def sync_status():
|
||||
"""Return last sync times and stats for contraband and awesomelist."""
|
||||
result = {}
|
||||
# Contraband stats
|
||||
cb_path = DATA_DIR / 'contraband.json'
|
||||
if cb_path.exists():
|
||||
cb_data = _load_contraband()
|
||||
result['contraband'] = {
|
||||
'last_sync': datetime.datetime.fromtimestamp(cb_path.stat().st_mtime).isoformat(),
|
||||
'total_entries': cb_data.get('total_entries', 0),
|
||||
'total_categories': cb_data.get('total_categories', 0),
|
||||
'total_starred': cb_data.get('total_starred', 0),
|
||||
'file_size': cb_path.stat().st_size
|
||||
}
|
||||
else:
|
||||
result['contraband'] = {'last_sync': None, 'total_entries': 0, 'total_categories': 0, 'total_starred': 0}
|
||||
|
||||
# Awesomelist stats
|
||||
al_index = DATA_DIR / 'awesomelist_index.json'
|
||||
al_dir = DATA_DIR / 'awesomelist'
|
||||
if al_index.exists():
|
||||
al_data = _load_awesomelist_index() or {}
|
||||
sector_count = len(list(al_dir.glob('sector_*.json'))) if al_dir.exists() else 0
|
||||
result['awesomelist'] = {
|
||||
'last_sync': datetime.datetime.fromtimestamp(al_index.stat().st_mtime).isoformat(),
|
||||
'total_sectors': al_data.get('total_sectors', sector_count),
|
||||
'total_lists': al_data.get('total_lists', 0),
|
||||
'total_entries': al_data.get('total_entries', 0),
|
||||
}
|
||||
else:
|
||||
result['awesomelist'] = {'last_sync': None, 'total_sectors': 0, 'total_lists': 0, 'total_entries': 0}
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/api/sync/contraband', methods=['POST'])
|
||||
@require_auth
|
||||
def sync_contraband():
|
||||
"""Trigger contraband sync."""
|
||||
try:
|
||||
script = Path(__file__).parent / 'contraband_sync.py'
|
||||
if not script.exists():
|
||||
return jsonify({'error': 'contraband_sync.py not found'}), 500
|
||||
result = subprocess.run(
|
||||
['python3', str(script)],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
cwd=str(script.parent)
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return jsonify({'status': 'ok', 'message': 'Contraband sync completed', 'log': result.stdout[-500:] if result.stdout else ''})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Sync failed', 'stderr': result.stderr[-500:] if result.stderr else ''}), 500
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Sync timed out (300s)'}), 504
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
@app.route('/api/sync/awesomelist', methods=['POST'])
|
||||
@require_auth
|
||||
def sync_awesomelist():
|
||||
"""Trigger awesomelist sync."""
|
||||
try:
|
||||
script = Path(__file__).parent / 'awesomelist_sync.py'
|
||||
if not script.exists():
|
||||
return jsonify({'error': 'awesomelist_sync.py not found'}), 500
|
||||
result = subprocess.run(
|
||||
['python3', str(script)],
|
||||
capture_output=True, text=True, timeout=600,
|
||||
cwd=str(script.parent)
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return jsonify({'status': 'ok', 'message': 'Awesomelist sync completed', 'log': result.stdout[-500:] if result.stdout else ''})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'Sync failed', 'stderr': result.stderr[-500:] if result.stderr else ''}), 500
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Sync timed out (600s)'}), 504
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
# ─── Cron Jobs Management ─────────────────────────────
|
||||
CRON_JOBS = [
|
||||
{'id': 'contraband', 'name': 'Contraband Sync', 'pattern': 'contraband_sync', 'schedule_default': '0 3 * * 0'},
|
||||
{'id': 'awesomelist', 'name': 'Awesomelist Sync', 'pattern': 'awesomelist_sync', 'schedule_default': '0 4 * * *'},
|
||||
{'id': 'sitrep', 'name': 'SITREP Generator', 'pattern': 'sitrep_generator', 'schedule_default': '0 7 * * *'},
|
||||
]
|
||||
|
||||
@app.route('/api/crons')
|
||||
@require_auth
|
||||
def get_crons():
|
||||
"""Parse crontab and return status of known cron jobs."""
|
||||
try:
|
||||
raw = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=5)
|
||||
crontab_lines = raw.stdout.strip().split('\n') if raw.returncode == 0 else []
|
||||
except Exception:
|
||||
crontab_lines = []
|
||||
|
||||
results = []
|
||||
for job in CRON_JOBS:
|
||||
found = False
|
||||
enabled = False
|
||||
schedule = job['schedule_default']
|
||||
full_line = ''
|
||||
for line in crontab_lines:
|
||||
if job['pattern'] in line:
|
||||
found = True
|
||||
full_line = line
|
||||
stripped = line.lstrip()
|
||||
enabled = not stripped.startswith('#')
|
||||
# Parse schedule from the line
|
||||
parts = stripped.lstrip('#').strip().split(None, 5)
|
||||
if len(parts) >= 5:
|
||||
schedule = ' '.join(parts[:5])
|
||||
break
|
||||
results.append({
|
||||
'id': job['id'],
|
||||
'name': job['name'],
|
||||
'found': found,
|
||||
'enabled': enabled,
|
||||
'schedule': schedule,
|
||||
'full_line': full_line
|
||||
})
|
||||
return jsonify(results)
|
||||
|
||||
@app.route('/api/crons/<job_id>/toggle', methods=['POST'])
|
||||
@require_auth
|
||||
def toggle_cron(job_id):
|
||||
"""Enable or disable a cron job by commenting/uncommenting its line."""
|
||||
job = next((j for j in CRON_JOBS if j['id'] == job_id), None)
|
||||
if not job:
|
||||
return jsonify({'error': 'Unknown cron job'}), 404
|
||||
|
||||
try:
|
||||
raw = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=5)
|
||||
if raw.returncode != 0:
|
||||
return jsonify({'error': 'Cannot read crontab'}), 500
|
||||
lines = raw.stdout.strip().split('\n')
|
||||
|
||||
new_lines = []
|
||||
toggled = False
|
||||
for line in lines:
|
||||
if job['pattern'] in line:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith('#'):
|
||||
# Enable it - remove leading #
|
||||
new_lines.append(stripped.lstrip('#').strip())
|
||||
else:
|
||||
# Disable it - add #
|
||||
new_lines.append('# ' + line)
|
||||
toggled = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if not toggled:
|
||||
return jsonify({'error': 'Cron job line not found in crontab'}), 404
|
||||
|
||||
# Write new crontab
|
||||
new_crontab = '\n'.join(new_lines) + '\n'
|
||||
proc = subprocess.run(['crontab', '-'], input=new_crontab, capture_output=True, text=True, timeout=5)
|
||||
if proc.returncode != 0:
|
||||
return jsonify({'error': 'Failed to write crontab: ' + proc.stderr}), 500
|
||||
|
||||
return jsonify({'ok': True, 'job_id': job_id})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ─── Backup Import ────────────────────────────────────
|
||||
@app.route('/api/backups/import', methods=['POST'])
|
||||
@require_auth
|
||||
def backup_import():
|
||||
"""Accept a ZIP file and restore JSON data files from it."""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file uploaded'}), 400
|
||||
f = request.files['file']
|
||||
if not f.filename.endswith('.zip'):
|
||||
return jsonify({'error': 'Only ZIP files accepted'}), 400
|
||||
|
||||
buf = io.BytesIO(f.read())
|
||||
restored = []
|
||||
with zipfile.ZipFile(buf, 'r') as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith('.json') and '/' not in name:
|
||||
# Only restore top-level JSON files
|
||||
target = DATA_DIR / name
|
||||
target.write_bytes(zf.read(name))
|
||||
restored.append(name)
|
||||
|
||||
return jsonify({'ok': True, 'restored': restored, 'count': len(restored)})
|
||||
except zipfile.BadZipFile:
|
||||
return jsonify({'error': 'Invalid ZIP file'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
# ─── Awesome Lists ────────────────────────────────────
|
||||
|
|
|
|||
455
js/admin.js
455
js/admin.js
|
|
@ -235,7 +235,11 @@ const AdminApp = {
|
|||
'section-dashboard': 'Dashboard',
|
||||
'section-posts': 'Posts',
|
||||
'section-editor': 'Editor',
|
||||
'section-changelog': 'Changelog',
|
||||
'section-tracks': 'Tracks',
|
||||
'section-sitrep': 'SITREP',
|
||||
'section-datasync': 'Data Sync',
|
||||
'section-cronjobs': 'Cron Jobs',
|
||||
'section-settings': 'Settings',
|
||||
'section-homepage': 'Homepage',
|
||||
'section-services': 'Services',
|
||||
|
|
@ -256,6 +260,7 @@ const AdminApp = {
|
|||
switch (name) {
|
||||
case 'section-dashboard': this.loadDashboard(); break;
|
||||
case 'section-posts': this.loadPosts(); break;
|
||||
case 'section-editor': this.loadEditorPostList(); break;
|
||||
case 'section-tracks': this.loadTracks(); break;
|
||||
case 'section-settings': this.loadSettings(); break;
|
||||
case 'section-homepage': this.loadHomepage(); break;
|
||||
|
|
@ -268,6 +273,10 @@ const AdminApp = {
|
|||
case 'section-contact': this.loadContactSettings(); break;
|
||||
case 'section-globe': this.loadGlobe(); break;
|
||||
case 'section-chatai': this.loadChatAI(); break;
|
||||
case 'section-changelog': this.loadChangelog(); break;
|
||||
case 'section-sitrep': this.loadSitrep(); break;
|
||||
case 'section-datasync': this.loadDataSync(); break;
|
||||
case 'section-cronjobs': this.loadCronJobs(); break;
|
||||
}
|
||||
|
||||
// Close mobile sidebar
|
||||
|
|
@ -638,6 +647,12 @@ const AdminApp = {
|
|||
const preview = document.getElementById('editorPreview');
|
||||
if (preview) preview.innerHTML = '';
|
||||
|
||||
// Reset post selector
|
||||
const sel = document.getElementById('editorPostSelect');
|
||||
if (sel) sel.value = '';
|
||||
const delBtn = document.getElementById('editorDeleteBtn');
|
||||
if (delBtn) delBtn.style.display = 'none';
|
||||
|
||||
// Reset HUD
|
||||
const moodEl = document.getElementById('hudMood');
|
||||
if (moodEl) moodEl.value = 'focused';
|
||||
|
|
@ -658,9 +673,6 @@ const AdminApp = {
|
|||
if (bpmEl) bpmEl.value = 80;
|
||||
const threatEl = document.getElementById('hudThreat');
|
||||
if (threatEl) threatEl.value = 'low';
|
||||
|
||||
const heading = document.getElementById('editorTitle');
|
||||
if (heading) heading.textContent = 'NEW POST';
|
||||
},
|
||||
|
||||
/* ─────────────────── EDITOR TOOLBAR ─────────────────── */
|
||||
|
|
@ -1763,6 +1775,443 @@ const AdminApp = {
|
|||
const el = document.getElementById(id);
|
||||
return el ? el.checked : false;
|
||||
}
|
||||
|
||||
/* ─────────────────── EDITOR POST LIST ─────────────────── */
|
||||
|
||||
async loadEditorPostList() {
|
||||
try {
|
||||
const res = await fetch(this.API + '/posts', { headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to load posts');
|
||||
const data = await res.json();
|
||||
const posts = Array.isArray(data) ? data : (data.posts || []);
|
||||
posts.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
|
||||
|
||||
const sel = document.getElementById('editorPostSelect');
|
||||
if (!sel) return;
|
||||
const currentVal = sel.value;
|
||||
sel.innerHTML = '<option value="">— NEW POST —</option>';
|
||||
posts.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.slug;
|
||||
opt.textContent = `${p.title || 'Untitled'} (${p.slug})`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (currentVal) sel.value = currentVal;
|
||||
} catch (err) {
|
||||
console.error('Editor post list error:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async loadPostForEdit(slug) {
|
||||
if (!slug) {
|
||||
this.clearEditor();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(this.API + '/posts/' + encodeURIComponent(slug), { headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to load post');
|
||||
const post = await res.json();
|
||||
|
||||
document.getElementById('editorPostId').value = post.slug || slug;
|
||||
document.getElementById('editorTitle').value = post.title || '';
|
||||
document.getElementById('editorSlug').value = post.slug || '';
|
||||
|
||||
if (post.date) {
|
||||
const dt = new Date(post.date);
|
||||
document.getElementById('editorDate').value = dt.toISOString().split('T')[0];
|
||||
document.getElementById('editorTime').value = dt.toTimeString().slice(0, 5);
|
||||
}
|
||||
|
||||
document.getElementById('editorTags').value = Array.isArray(post.tags) ? post.tags.join(', ') : (post.tags || '');
|
||||
document.getElementById('editorExcerpt').value = post.excerpt || '';
|
||||
document.getElementById('editorContent').value = post.content || '';
|
||||
|
||||
// Operator HUD
|
||||
const moodEl = document.getElementById('hudMood');
|
||||
if (moodEl) moodEl.value = post.mood || 'focused';
|
||||
|
||||
const hudMap = { hudEnergy: 'energy', hudMotivation: 'motivation', hudFocus: 'focus', hudDifficulty: 'difficulty' };
|
||||
Object.entries(hudMap).forEach(([id, key]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.value = post[key] || 3;
|
||||
const valEl = document.getElementById(id + 'Val');
|
||||
if (valEl) valEl.textContent = el.value;
|
||||
}
|
||||
});
|
||||
|
||||
const coffeeEl = document.getElementById('hudCoffee');
|
||||
if (coffeeEl) { coffeeEl.value = post.coffee || 0; const v = document.getElementById('hudCoffeeVal'); if (v) v.textContent = coffeeEl.value; }
|
||||
const bpmEl = document.getElementById('hudBPM');
|
||||
if (bpmEl) { bpmEl.value = post.bpm || post.heart_rate || 72; const v = document.getElementById('hudBPMVal'); if (v) v.textContent = bpmEl.value; }
|
||||
const threatEl = document.getElementById('hudThreat');
|
||||
if (threatEl) threatEl.value = post.threat_level || 'low';
|
||||
|
||||
// Show delete button
|
||||
const delBtn = document.getElementById('editorDeleteBtn');
|
||||
if (delBtn) delBtn.style.display = 'inline-block';
|
||||
|
||||
this.updatePreview();
|
||||
this.notify('Post loaded: ' + (post.title || slug));
|
||||
} catch (err) {
|
||||
console.error('Load post for edit error:', err);
|
||||
this.notify('Failed to load post', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/* ─────────────────── CHANGELOG ─────────────────── */
|
||||
|
||||
async loadChangelog() {
|
||||
try {
|
||||
const res = await fetch(this.API + '/changelog', { headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to load changelog');
|
||||
const entries = await res.json();
|
||||
const list = document.getElementById('changelogList');
|
||||
if (!list) return;
|
||||
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
list.innerHTML = '<div style="color:#555;text-align:center;padding:2rem;">No changelog entries yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const typeColors = { feature: '#00ffc8', fix: '#ffcc00', security: '#ff4444', breaking: '#ff0066', update: '#888' };
|
||||
|
||||
list.innerHTML = entries.map(e => {
|
||||
const changes = Array.isArray(e.changes) ? e.changes : [];
|
||||
const typeColor = typeColors[e.type] || '#888';
|
||||
return `<div class="admin-card" style="margin-bottom:1rem;border-left:3px solid ${typeColor};">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong style="color:${typeColor};">[${(e.type || 'update').toUpperCase()}]</strong>
|
||||
<strong>${this.escapeHtml(e.version || '')}</strong> — ${this.escapeHtml(e.title || '')}
|
||||
<span style="color:#555;margin-left:1rem;">${e.date || ''}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn-sm btn-edit" onclick="AdminApp.editChangelog(${e.id})" title="Edit">✎</button>
|
||||
<button class="btn-sm btn-delete" onclick="AdminApp.deleteChangelog(${e.id})" title="Delete">✗</button>
|
||||
</div>
|
||||
</div>
|
||||
${e.description ? `<p style="color:#aaa;margin:0.5rem 0 0;">${this.escapeHtml(e.description)}</p>` : ''}
|
||||
${changes.length ? `<ul style="margin:0.5rem 0 0;padding-left:1.2rem;color:#888;">${changes.map(c => `<li>${this.escapeHtml(c)}</li>`).join('')}</ul>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Changelog load error:', err);
|
||||
this.notify('Failed to load changelog', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async saveChangelog() {
|
||||
const editId = document.getElementById('clEditId').value;
|
||||
const version = this.getVal('clVersion');
|
||||
const date = this.getVal('clDate') || new Date().toISOString().split('T')[0];
|
||||
const type = this.getVal('clType') || 'update';
|
||||
const title = this.getVal('clTitle');
|
||||
const description = this.getVal('clDescription');
|
||||
const changesRaw = this.getVal('clChanges');
|
||||
const changes = changesRaw.split('\n').map(c => c.trim()).filter(c => c);
|
||||
|
||||
if (!title) { this.notify('Title is required', 'error'); return; }
|
||||
|
||||
const payload = { version, date, type, title, description, changes };
|
||||
|
||||
try {
|
||||
let url, method;
|
||||
if (editId) {
|
||||
url = this.API + '/changelog/' + editId;
|
||||
method = 'PUT';
|
||||
} else {
|
||||
url = this.API + '/changelog';
|
||||
method = 'POST';
|
||||
}
|
||||
const res = await fetch(url, { method, headers: this.authHeaders(), body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error('Save failed');
|
||||
this.notify(editId ? 'Entry updated' : 'Entry added');
|
||||
this.clearChangelog();
|
||||
this.loadChangelog();
|
||||
} catch (err) {
|
||||
console.error('Changelog save error:', err);
|
||||
this.notify('Failed to save changelog entry', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
clearChangelog() {
|
||||
document.getElementById('clEditId').value = '';
|
||||
document.getElementById('clVersion').value = '';
|
||||
document.getElementById('clDate').value = '';
|
||||
document.getElementById('clType').value = 'update';
|
||||
document.getElementById('clTitle').value = '';
|
||||
document.getElementById('clDescription').value = '';
|
||||
document.getElementById('clChanges').value = '';
|
||||
},
|
||||
|
||||
async editChangelog(id) {
|
||||
try {
|
||||
const res = await fetch(this.API + '/changelog', { headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to load');
|
||||
const entries = await res.json();
|
||||
const entry = entries.find(e => e.id === id);
|
||||
if (!entry) { this.notify('Entry not found', 'error'); return; }
|
||||
|
||||
document.getElementById('clEditId').value = entry.id;
|
||||
document.getElementById('clVersion').value = entry.version || '';
|
||||
document.getElementById('clDate').value = entry.date || '';
|
||||
document.getElementById('clType').value = entry.type || 'update';
|
||||
document.getElementById('clTitle').value = entry.title || '';
|
||||
document.getElementById('clDescription').value = entry.description || '';
|
||||
document.getElementById('clChanges').value = (entry.changes || []).join('\n');
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} catch (err) {
|
||||
this.notify('Failed to load entry for editing', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteChangelog(id) {
|
||||
if (!confirm('Delete this changelog entry?')) return;
|
||||
try {
|
||||
const res = await fetch(this.API + '/changelog/' + id, { method: 'DELETE', headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error('Delete failed');
|
||||
this.notify('Entry deleted');
|
||||
this.loadChangelog();
|
||||
} catch (err) {
|
||||
this.notify('Failed to delete entry', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/* ─────────────────── SITREP ─────────────────── */
|
||||
|
||||
async loadSitrep() {
|
||||
try {
|
||||
// Load list
|
||||
const listRes = await fetch(this.API + '/sitrep/list', { headers: this.authHeaders() });
|
||||
if (listRes.ok) {
|
||||
const listData = await listRes.json();
|
||||
const dates = listData.dates || [];
|
||||
|
||||
document.getElementById('sitrepTotal').textContent = listData.total || 0;
|
||||
document.getElementById('sitrepLatestDate').textContent = dates.length ? dates[0].date : 'NONE';
|
||||
}
|
||||
|
||||
// Load today's sitrep
|
||||
const todayRes = await fetch(this.API + '/sitrep', { headers: this.authHeaders() });
|
||||
if (todayRes.ok) {
|
||||
const sitrep = await todayRes.json();
|
||||
if (sitrep && !sitrep.error) {
|
||||
document.getElementById('sitrepStatus').textContent = sitrep.is_today ? 'TODAY ✓' : 'STALE';
|
||||
document.getElementById('sitrepStatus').style.color = sitrep.is_today ? '#00ffc8' : '#ffcc00';
|
||||
|
||||
const summary = document.getElementById('sitrepTodaySummary');
|
||||
if (summary) {
|
||||
summary.style.display = 'block';
|
||||
document.getElementById('sitrepHeadline').textContent = sitrep.headline || '';
|
||||
document.getElementById('sitrepSummaryText').innerHTML = (sitrep.sections || []).map(s =>
|
||||
`<div style="margin-bottom:1rem;"><strong style="color:#00ffc8;">${this.escapeHtml(s.title || '')}</strong><p style="color:#aaa;">${this.escapeHtml(s.content || '')}</p></div>`
|
||||
).join('');
|
||||
}
|
||||
} else {
|
||||
document.getElementById('sitrepStatus').textContent = 'NONE';
|
||||
document.getElementById('sitrepStatus').style.color = '#ff4444';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('sitrepStatus').textContent = 'NONE';
|
||||
document.getElementById('sitrepStatus').style.color = '#ff4444';
|
||||
}
|
||||
|
||||
// Archive list
|
||||
const archRes = await fetch(this.API + '/sitrep/list', { headers: this.authHeaders() });
|
||||
if (archRes.ok) {
|
||||
const archData = await archRes.json();
|
||||
const archEl = document.getElementById('sitrepArchive');
|
||||
if (archEl) {
|
||||
const dates = archData.dates || [];
|
||||
if (dates.length === 0) {
|
||||
archEl.innerHTML = '<div style="color:#555;">No reports in archive</div>';
|
||||
} else {
|
||||
archEl.innerHTML = dates.map(d => `<div class="admin-card" style="margin-bottom:0.5rem;display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong>${d.date}</strong>
|
||||
<span style="color:#aaa;margin-left:1rem;">${this.escapeHtml(d.headline || '')}</span>
|
||||
</div>
|
||||
<div style="color:#555;">
|
||||
${d.sources_used || 0} sources · ${d.model || 'unknown'}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('SITREP load error:', err);
|
||||
this.notify('Failed to load SITREP data', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async generateSitrep() {
|
||||
const btn = document.getElementById('sitrepGenerateBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ GENERATING...'; }
|
||||
|
||||
try {
|
||||
const res = await fetch(this.API + '/sitrep/generate', { method: 'POST', headers: this.authHeaders() });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === 'ok') {
|
||||
this.notify('SITREP generated for ' + (data.date || 'today'));
|
||||
this.loadSitrep();
|
||||
} else {
|
||||
this.notify('Generation failed: ' + (data.message || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('SITREP generate error:', err);
|
||||
this.notify('Failed to generate SITREP', 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '⚡ GENERATE NOW'; }
|
||||
}
|
||||
},
|
||||
|
||||
/* ─────────────────── DATA SYNC ─────────────────── */
|
||||
|
||||
async loadDataSync() {
|
||||
try {
|
||||
const res = await fetch(this.API + '/sync/status', { headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to load sync status');
|
||||
const data = await res.json();
|
||||
|
||||
// Contraband stats
|
||||
const cb = data.contraband || {};
|
||||
document.getElementById('syncCbEntries').textContent = (cb.total_entries || 0).toLocaleString();
|
||||
document.getElementById('syncCbCategories').textContent = cb.total_categories || 0;
|
||||
document.getElementById('syncCbStarred').textContent = (cb.total_starred || 0).toLocaleString();
|
||||
document.getElementById('syncCbLastSync').textContent = cb.last_sync ? new Date(cb.last_sync).toLocaleString('en-GB') : 'NEVER';
|
||||
|
||||
// Awesomelist stats
|
||||
const al = data.awesomelist || {};
|
||||
document.getElementById('syncAlSectors').textContent = al.total_sectors || 0;
|
||||
document.getElementById('syncAlLists').textContent = al.total_lists || 0;
|
||||
document.getElementById('syncAlEntries').textContent = (al.total_entries || 0).toLocaleString();
|
||||
document.getElementById('syncAlLastSync').textContent = al.last_sync ? new Date(al.last_sync).toLocaleString('en-GB') : 'NEVER';
|
||||
} catch (err) {
|
||||
console.error('Sync status error:', err);
|
||||
this.notify('Failed to load sync status', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async runSync(type) {
|
||||
const btnId = type === 'contraband' ? 'syncCbBtn' : 'syncAlBtn';
|
||||
const btn = document.getElementById(btnId);
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ SYNCING...'; }
|
||||
|
||||
try {
|
||||
const res = await fetch(this.API + '/sync/' + type, { method: 'POST', headers: this.authHeaders() });
|
||||
const data = await res.json();
|
||||
|
||||
// Show log
|
||||
const logEl = document.getElementById('syncLog');
|
||||
const logContent = document.getElementById('syncLogContent');
|
||||
if (logEl && logContent) {
|
||||
logEl.style.display = 'block';
|
||||
logContent.textContent = data.log || data.stderr || data.message || 'No output';
|
||||
}
|
||||
|
||||
if (res.ok && data.status === 'ok') {
|
||||
this.notify(type + ' sync completed');
|
||||
this.loadDataSync();
|
||||
} else {
|
||||
this.notify('Sync failed: ' + (data.message || data.stderr || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Sync error:', err);
|
||||
this.notify('Sync failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '⚡ SYNC NOW'; }
|
||||
}
|
||||
},
|
||||
|
||||
/* ─────────────────── CRON JOBS ─────────────────── */
|
||||
|
||||
async loadCronJobs() {
|
||||
try {
|
||||
const res = await fetch(this.API + '/crons', { headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to load cron jobs');
|
||||
const jobs = await res.json();
|
||||
const list = document.getElementById('cronJobsList');
|
||||
if (!list) return;
|
||||
|
||||
if (!Array.isArray(jobs) || jobs.length === 0) {
|
||||
list.innerHTML = '<div style="color:#555;text-align:center;padding:2rem;">No cron jobs configured</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = jobs.map(j => {
|
||||
const statusColor = j.enabled ? '#00ffc8' : (j.found ? '#ffcc00' : '#ff4444');
|
||||
const statusText = j.enabled ? 'ACTIVE' : (j.found ? 'DISABLED' : 'NOT FOUND');
|
||||
return `<div class="admin-card" style="margin-bottom:1rem;border-left:3px solid ${statusColor};display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong>${this.escapeHtml(j.name)}</strong>
|
||||
<span style="color:${statusColor};margin-left:1rem;">[${statusText}]</span>
|
||||
<div style="color:#555;margin-top:0.3rem;font-family:'JetBrains Mono',monospace;font-size:0.85rem;">
|
||||
Schedule: <code>${this.escapeHtml(j.schedule)}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
${j.found ? `<button class="action-btn ${j.enabled ? '' : 'accent'}" onclick="AdminApp.toggleCron('${j.id}')" style="min-width:100px;">
|
||||
${j.enabled ? '⏸ DISABLE' : '▶ ENABLE'}
|
||||
</button>` : '<span style="color:#ff4444;">Not in crontab</span>'}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Cron jobs load error:', err);
|
||||
this.notify('Failed to load cron jobs', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleCron(jobId) {
|
||||
try {
|
||||
const res = await fetch(this.API + '/crons/' + jobId + '/toggle', { method: 'POST', headers: this.authHeaders() });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Toggle failed');
|
||||
}
|
||||
this.notify('Cron job toggled');
|
||||
this.loadCronJobs();
|
||||
} catch (err) {
|
||||
console.error('Cron toggle error:', err);
|
||||
this.notify('Failed to toggle cron: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/* ─────────────────── BACKUP IMPORT ─────────────────── */
|
||||
|
||||
async importBackup() {
|
||||
const fileInput = document.getElementById('backupImportFile');
|
||||
if (!fileInput || !fileInput.files.length) {
|
||||
this.notify('Please select a ZIP file first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('This will overwrite existing data files. Are you sure?')) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
try {
|
||||
const res = await fetch(this.API + '/backups/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('jaeswift_token') },
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) {
|
||||
this.notify(`Backup restored: ${data.count} files (${data.restored.join(', ')})`);
|
||||
fileInput.value = '';
|
||||
} else {
|
||||
this.notify('Import failed: ' + (data.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
this.notify('Failed to import backup', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
/* ─────────────────── BOOTSTRAP ─────────────────── */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue