mirror of
https://github.com/zvx-echo6/recon.git
synced 2026-05-20 06:34:40 +02:00
Initial commit: RECON codebase baseline
Current state of the pipeline code as of 2026-04-14 (Phase 1 scaffolding complete). Config has new_pipeline.enabled=false and crawler.sites=[] per refactor plan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
563c16bb71
59 changed files with 18327 additions and 0 deletions
39
templates/base.html
Normal file
39
templates/base.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>RECON // Aurora Intelligence Pipeline{% if page_title %} — {{ page_title }}{% endif %}</title>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="/static/css/recon.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left"><h1><span id="heartbeat" class="heartbeat"></span>RECON</h1><span class="header-subtitle">AURORA INTELLIGENCE PIPELINE</span></div>
|
||||
<div class="flex gap-16">
|
||||
<div class="quick-stats">
|
||||
<span>Docs: <span id="qs-docs">—</span></span>
|
||||
<span>Vectors: <span id="qs-vectors">—</span></span>
|
||||
<span>Pipeline: <span id="qs-pipeline">—</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-domain">
|
||||
<a href="/"{% if domain == 'knowledge' %} class="active"{% endif %}>Knowledge</a>
|
||||
<a href="/peertube"{% if domain == 'peertube' %} class="active"{% endif %}>PeerTube</a>
|
||||
<a href="/search"{% if domain == 'search' %} class="active"{% endif %}>Search</a>
|
||||
<a href="/settings/keys"{% if domain == 'settings' %} class="active"{% endif %}>Settings</a>
|
||||
</div>
|
||||
{% if subnav %}
|
||||
<div class="nav-sub">
|
||||
{% for item in subnav %}
|
||||
<a href="{{ item.href }}"{% if item.href == active_page %} class="active"{% endif %}>{{ item.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="content" id="main">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="/static/js/common.js"></script>
|
||||
<script>document.addEventListener('DOMContentLoaded', function() { RECON.loadQuickStats(); });</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
53
templates/knowledge/catalogue.html
Normal file
53
templates/knowledge/catalogue.html
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="section-title mb-16">Document Catalogue</h3>
|
||||
|
||||
{% if sources %}
|
||||
<div class="mb-16">
|
||||
<a href="/catalogue" class="btn{% if not current_source %} active{% endif %}" style="margin-right:4px;">All</a>
|
||||
{% for s in sources %}
|
||||
<a href="/catalogue?source={{ s }}" class="btn{% if current_source == s %} active{% endif %}" style="margin-right:4px;">{{ s }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-dim text-xs mb-16">
|
||||
Showing {{ docs|length }}{% if total_count %} of {{ total_count }}{% endif %} documents
|
||||
{% if current_source %} in <strong>{{ current_source }}</strong>{% endif %}
|
||||
(page {{ page }} of {{ total_pages }})
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tr><th>Filename</th><th>Source</th><th>Status</th><th>Pages</th><th>Concepts</th><th>Vectors</th></tr>
|
||||
{% for d in docs %}
|
||||
<tr>
|
||||
<td>{{ d.filename or '?' }}</td>
|
||||
<td>{{ d.source or '' }}</td>
|
||||
<td><span class="status status-{{ d.status or 'unknown' }}">{{ d.status or 'unknown' }}</span></td>
|
||||
<td>{{ d.pages_extracted or 0 }}</td>
|
||||
<td>{{ d.concepts_extracted or 0 }}</td>
|
||||
<td>{{ d.vectors_inserted or 0 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/catalogue?page={{ page - 1 }}{% if current_source %}&source={{ current_source }}{% endif %}&per_page={{ per_page }}">«</a>
|
||||
{% endif %}
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span class="current">{{ p }}</span>
|
||||
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="/catalogue?page={{ p }}{% if current_source %}&source={{ current_source }}{% endif %}&per_page={{ per_page }}">{{ p }}</a>
|
||||
{% elif p == 4 or p == total_pages - 3 %}
|
||||
<span class="text-dim">...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if page < total_pages %}
|
||||
<a href="/catalogue?page={{ page + 1 }}{% if current_source %}&source={{ current_source }}{% endif %}&per_page={{ per_page }}">»</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
72
templates/knowledge/dashboard.html
Normal file
72
templates/knowledge/dashboard.html
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div id="kb-dashboard">
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card"><div class="label">Catalogued</div><div class="value" id="kv-catalogued">—</div><div class="sublabel">total known documents</div></div>
|
||||
<div class="stat-card"><div class="label">In Pipeline</div><div class="value" id="kv-pipeline">—</div><div class="sublabel" id="kv-pipeline-sub">processing</div></div>
|
||||
<div class="stat-card"><div class="label">Complete</div><div class="value" id="kv-complete">—</div><div class="sublabel">in Qdrant</div></div>
|
||||
<div class="stat-card"><div class="label">Failed</div><div class="value" id="kv-failed">—</div><div class="sublabel"> </div></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-24">
|
||||
<div class="flex-between mb-16" style="margin-bottom:4px;font-size:11px;color:#888;">
|
||||
<span id="progress-label">Pipeline Progress</span>
|
||||
<span id="progress-pct"></span>
|
||||
</div>
|
||||
<div id="progress-bar" class="pipeline-bar"></div>
|
||||
<div id="progress-legend" class="pipeline-legend"></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid grid-3">
|
||||
<div class="stat-card"><div class="label">Concepts</div><div class="value" id="kv-concepts">—</div><div class="sublabel">extracted</div></div>
|
||||
<div class="stat-card"><div class="label">Vectors</div><div class="value" id="kv-vectors">—</div><div class="sublabel">in Qdrant</div></div>
|
||||
<div class="stat-card"><div class="label">Pages</div><div class="value" id="kv-pages">—</div><div class="sublabel">processed</div></div>
|
||||
</div>
|
||||
|
||||
<div id="pipeline-activity" class="panel" style="display:none;">
|
||||
<h3 style="color:#ffa500;font-size:13px;margin-bottom:8px;">Pipeline Activity</h3>
|
||||
<div id="activity-content" style="font-size:12px;color:#ccc;"></div>
|
||||
</div>
|
||||
|
||||
<div id="qdrant-health" class="panel" style="padding:10px 16px;font-size:12px;color:#888;">
|
||||
Qdrant: <span id="qdrant-status">checking...</span>
|
||||
</div>
|
||||
|
||||
<div id="kb-chart-container" class="panel" style="display:none;">
|
||||
<h3 class="section-title" style="margin-bottom:8px;">Pipeline Activity (24h)</h3>
|
||||
<canvas id="kb-chart" width="800" height="200" style="width:100%;height:200px;"></canvas>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title" id="sources-toggle" style="cursor:pointer;user-select:none;"><span id="sources-arrow">▶</span> Sources</h3>
|
||||
<table>
|
||||
<thead id="sources-thead" style="display:none;"><tr><th>Source</th><th>Type</th><th>Catalogued</th><th>Complete</th><th>In Pipeline</th><th>Progress</th><th>Concepts</th><th>Vectors</th></tr></thead>
|
||||
<tbody id="sources-tbody" style="display:none;"><tr><td colspan="8" class="text-dim">Loading...</td></tr></tbody>
|
||||
<tfoot id="sources-tfoot"></tfoot>
|
||||
</table>
|
||||
|
||||
<div class="grid-2 mt-24">
|
||||
<div>
|
||||
<h3 class="section-title">Domain Distribution</h3>
|
||||
<div id="domain-bars" class="text-small">Loading...</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="section-title">Knowledge Type</h3>
|
||||
<div id="knowledge-type-bars" class="text-small">Loading...</div>
|
||||
<div id="knowledge-type-migration" class="text-small" style="margin-top:6px;color:#666;font-size:11px;"></div>
|
||||
<h3 class="section-title" style="margin-top:16px;">Complexity</h3>
|
||||
<div id="complexity-bars" class="text-small">Loading...</div>
|
||||
<div id="complexity-migration" class="text-small" style="margin-top:6px;color:#666;font-size:11px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title mt-24">Recently Completed</h3>
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Type</th><th>Concepts</th><th>Vectors</th></tr></thead>
|
||||
<tbody id="recent-tbody"><tr><td colspan="4" class="text-dim">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/charts.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
{% endblock %}
|
||||
56
templates/knowledge/failures.html
Normal file
56
templates/knowledge/failures.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 style="color:#ff4444;margin-bottom:16px;">Failed Documents</h3>
|
||||
{% if not failures %}
|
||||
<p class="text-dim">No failures.</p>
|
||||
{% else %}
|
||||
<div style="margin-bottom:16px;">
|
||||
<button class="btn" id="retry-all-btn" onclick="retryAll()">Retry All ({{ failures|length }})</button>
|
||||
<span id="retry-all-status" style="margin-left:12px;font-size:12px;"></span>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th>Filename</th><th>Error</th><th>Age</th><th>Retries</th><th>Actions</th></tr>
|
||||
{% for f in failures %}
|
||||
<tr>
|
||||
<td>{{ f.filename or '?' }}</td>
|
||||
<td style="color:#ff4444;font-size:11px;">{{ (f.error_message or 'unknown')[:100] }}</td>
|
||||
<td class="text-dim text-xs">{{ f.discovered_at or '' }}</td>
|
||||
<td>{{ f.retry_count or 0 }}</td>
|
||||
<td>
|
||||
<form method="post" action="/api/retry/{{ f.hash }}" style="display:inline;">
|
||||
<button class="btn" type="submit">Retry</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function retryAll() {
|
||||
var btn = document.getElementById('retry-all-btn');
|
||||
var status = document.getElementById('retry-all-status');
|
||||
if (!confirm('Retry all {{ failures|length }} failed documents?')) return;
|
||||
btn.disabled = true;
|
||||
status.style.color = '#ffa500';
|
||||
status.textContent = 'Retrying...';
|
||||
try {
|
||||
var resp = await fetch('/api/retry-all', {method: 'POST'});
|
||||
var data = await resp.json();
|
||||
if (resp.ok) {
|
||||
status.style.color = '#00ff41';
|
||||
status.textContent = 'Retried ' + data.count + ' documents';
|
||||
setTimeout(function() { location.reload(); }, 2000);
|
||||
} else {
|
||||
status.style.color = '#ff4444';
|
||||
status.textContent = data.error || 'Failed';
|
||||
}
|
||||
} catch(e) {
|
||||
status.style.color = '#ff4444';
|
||||
status.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
83
templates/knowledge/upload.html
Normal file
83
templates/knowledge/upload.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="section-title mb-16">Upload PDF</h3>
|
||||
<div class="panel">
|
||||
<form id="upload-form" enctype="multipart/form-data">
|
||||
<div class="mb-16">
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">PDF File</label>
|
||||
<input type="file" name="file" accept=".pdf" id="upload-file"
|
||||
style="background:#0a0a0a;border:1px solid #333;color:#c0c0c0;padding:8px;width:100%;font-family:inherit;">
|
||||
</div>
|
||||
<div class="mb-16">
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Category</label>
|
||||
<input type="text" name="category" id="upload-category" list="cat-list" class="search-box"
|
||||
placeholder="Select or type a category..." style="margin-bottom:0;">
|
||||
<datalist id="cat-list">{{ options_html|safe }}</datalist>
|
||||
</div>
|
||||
<button type="submit" class="btn" id="upload-btn">Upload</button>
|
||||
<span id="upload-status" style="margin-left:12px;font-size:12px;"></span>
|
||||
</form>
|
||||
</div>
|
||||
<div id="upload-result" style="display:none;" class="panel"></div>
|
||||
|
||||
<h3 class="section-title">Recent Documents</h3>
|
||||
<table>
|
||||
<tr><th>Filename</th><th>Source</th><th>Status</th></tr>
|
||||
{% for d in recent %}
|
||||
<tr>
|
||||
<td>{{ d.filename or '?' }}</td>
|
||||
<td>{{ d.source or '' }}</td>
|
||||
<td><span class="status status-{{ d.status or 'unknown' }}">{{ d.status or 'unknown' }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('upload-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('upload-btn');
|
||||
var status = document.getElementById('upload-status');
|
||||
var result = document.getElementById('upload-result');
|
||||
var fileInput = document.getElementById('upload-file');
|
||||
var category = document.getElementById('upload-category').value;
|
||||
|
||||
if (!fileInput.files.length) {
|
||||
status.style.color = '#ff4444';
|
||||
status.textContent = 'No file selected';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
status.style.color = '#ffa500';
|
||||
status.textContent = 'Uploading...';
|
||||
result.style.display = 'none';
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
formData.append('category', category);
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/upload', { method: 'POST', body: formData });
|
||||
var data = await resp.json();
|
||||
if (resp.ok) {
|
||||
status.style.color = '#00ff41';
|
||||
status.textContent = 'Upload successful';
|
||||
result.style.display = 'block';
|
||||
result.innerHTML = '<span style="color:#00ff41;">Queued for processing</span><br>' +
|
||||
'<span class="text-dim">Hash: ' + data.hash + '</span><br>' +
|
||||
'<span class="text-dim">File: ' + data.filename + '</span><br>' +
|
||||
'<span class="text-dim">Category: ' + data.source + '/' + data.category + '</span>';
|
||||
fileInput.value = '';
|
||||
} else {
|
||||
status.style.color = '#ff4444';
|
||||
status.textContent = data.error || 'Upload failed';
|
||||
}
|
||||
} catch (err) {
|
||||
status.style.color = '#ff4444';
|
||||
status.textContent = 'Network error: ' + err.message;
|
||||
}
|
||||
btn.disabled = false;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
76
templates/knowledge/web_ingest.html
Normal file
76
templates/knowledge/web_ingest.html
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="section-title mb-16">Web Ingest</h3>
|
||||
<div style="margin-bottom:8px;">
|
||||
<a href="#single" class="btn active" onclick="showSection('single')" id="tab-single">Single/Batch URL</a>
|
||||
<a href="#crawl" class="btn" onclick="showSection('crawl')" id="tab-crawl">Site Crawl</a>
|
||||
</div>
|
||||
|
||||
<div id="section-single">
|
||||
<div class="panel">
|
||||
<div class="mb-16">
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">URL(s) — one per line for batch</label>
|
||||
<textarea id="wi-urls" class="search-box" rows="4" placeholder="https://example.com/article" style="resize:vertical;margin-bottom:0;"></textarea>
|
||||
</div>
|
||||
<div class="mb-16">
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Category</label>
|
||||
<input type="text" id="wi-category" list="wi-cat-list" class="search-box" value="Web"
|
||||
placeholder="Category..." style="margin-bottom:0;">
|
||||
<datalist id="wi-cat-list">{{ options_html|safe }}</datalist>
|
||||
</div>
|
||||
<button class="btn" id="wi-btn" onclick="doWebIngest()">Ingest</button>
|
||||
<span id="wi-status" style="margin-left:12px;font-size:12px;"></span>
|
||||
</div>
|
||||
<div id="wi-results" style="display:none;" class="panel" style="max-height:300px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="section-crawl" style="display:none;">
|
||||
<div class="panel">
|
||||
<div class="mb-16">
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Site URL</label>
|
||||
<input type="text" id="crawl-url" class="search-box" placeholder="https://example.com" style="margin-bottom:0;">
|
||||
</div>
|
||||
<div class="grid-2 mb-16">
|
||||
<div>
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Category</label>
|
||||
<input type="text" id="crawl-category" list="wi-cat-list" class="search-box" value="Web" style="margin-bottom:0;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Max Pages</label>
|
||||
<input type="number" id="crawl-max-pages" class="search-box" value="500" min="1" max="5000" style="margin-bottom:0;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2 mb-16">
|
||||
<div>
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Include Paths (comma-separated)</label>
|
||||
<input type="text" id="crawl-include" class="search-box" placeholder="/docs/, /blog/" style="margin-bottom:0;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Exclude Paths (comma-separated)</label>
|
||||
<input type="text" id="crawl-exclude" class="search-box" placeholder="/search, /login" style="margin-bottom:0;">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" id="crawl-preview-btn" onclick="doCrawl(true)">Preview</button>
|
||||
<button class="btn" id="crawl-btn" onclick="doCrawl(false)" style="margin-left:8px;">Crawl & Ingest</button>
|
||||
<span id="crawl-status" style="margin-left:12px;font-size:12px;"></span>
|
||||
</div>
|
||||
<div id="crawl-results" style="display:none;" class="panel" style="max-height:400px;overflow-y:auto;font-size:12px;"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title mt-24">Recent Web Ingestions</h3>
|
||||
<table>
|
||||
<tr><th>Title</th><th>Source/Category</th><th>Status</th><th>Pages</th><th>Concepts</th></tr>
|
||||
{% for d in web_docs %}
|
||||
<tr>
|
||||
<td title="{{ d.path or '' }}" style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ d.book_title or d.filename or '?' }}</td>
|
||||
<td>{{ d.source or '' }}/{{ d.category or '' }}</td>
|
||||
<td><span class="status status-{{ d.status or 'unknown' }}">{{ d.status or 'unknown' }}</span></td>
|
||||
<td>{{ d.pages_extracted or 0 }}</td>
|
||||
<td>{{ d.concepts_extracted or 0 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/web-ingest.js"></script>
|
||||
{% endblock %}
|
||||
53
templates/peertube/channels.html
Normal file
53
templates/peertube/channels.html
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="section-title mb-16">PeerTube Channels</h3>
|
||||
|
||||
<div class="stat-grid" id="pt-stats" style="margin-bottom:24px;">
|
||||
<div class="stat-card"><div class="value" id="pt-total-ch">—</div><div class="label">Channels</div></div>
|
||||
<div class="stat-card"><div class="value" id="pt-total-vid">—</div><div class="label">Videos</div></div>
|
||||
<div class="stat-card"><div class="value" id="pt-dl-status">—</div><div class="label">Downloader</div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="flex gap-8" style="flex-wrap:wrap;align-items:flex-end;margin-bottom:12px;">
|
||||
<div style="flex:1;min-width:250px;">
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">YouTube URL</label>
|
||||
<input type="text" id="pt-yt-url" class="search-box" placeholder="https://www.youtube.com/@ChannelName" style="margin-bottom:0;width:100%;">
|
||||
</div>
|
||||
<div style="min-width:150px;">
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Category</label>
|
||||
<input type="text" id="pt-category" list="pt-cat-list" class="search-box" placeholder="e.g. OPSEC/Privacy" style="margin-bottom:0;width:100%;">
|
||||
<datalist id="pt-cat-list"></datalist>
|
||||
</div>
|
||||
<div style="min-width:60px;">
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Priority</label>
|
||||
<select id="pt-priority" style="background:#0a0a0a;border:1px solid #333;color:#c0c0c0;padding:6px 10px;font-family:inherit;font-size:12px;width:100%;">
|
||||
<option value="M">M</option>
|
||||
<option value="H">H</option>
|
||||
<option value="L">L</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" id="pt-add-btn" onclick="addChannel()">Add Channel</button>
|
||||
</div>
|
||||
<div id="pt-feedback" style="font-size:12px;min-height:18px;"></div>
|
||||
</div>
|
||||
|
||||
<div style="background:#111;border:1px solid #222;overflow-x:auto;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;" id="pt-channel-table">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<th style="text-align:left;padding:10px;">Channel</th>
|
||||
<th style="text-align:center;padding:10px;">Videos</th>
|
||||
<th style="text-align:left;padding:10px;">Category</th>
|
||||
<th style="text-align:center;padding:10px;">Pri</th>
|
||||
<th style="text-align:center;padding:10px;">Status</th>
|
||||
<th style="text-align:center;padding:10px;width:60px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pt-channel-tbody"><tr><td colspan="6" style="text-align:center;padding:20px;color:#555;">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/channels.js"></script>
|
||||
{% endblock %}
|
||||
53
templates/peertube/dashboard.html
Normal file
53
templates/peertube/dashboard.html
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div id="pt-dashboard">
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(6, 1fr);">
|
||||
<div class="stat-card"><div class="label">Published</div><div class="value" id="pt-published">—</div></div>
|
||||
<div class="stat-card"><div class="label">In Pipeline</div><div class="value" id="pt-in-pipeline">—</div></div>
|
||||
<div class="stat-card"><div class="label">Failed</div><div class="value" id="pt-failed">—</div></div>
|
||||
<div class="stat-card"><div class="label">Import Rate</div><div class="value" id="pt-import-rate">—</div><div class="sublabel">/hour</div></div>
|
||||
<div class="stat-card"><div class="label">GPU Util</div><div class="value" id="pt-gpu-util">—</div><div class="sublabel">%</div></div>
|
||||
<div class="stat-card"><div class="label">GPU Temp</div><div class="value" id="pt-gpu-temp">—</div><div class="sublabel">°C</div></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-24">
|
||||
<div class="flex-between" style="margin-bottom:4px;font-size:11px;color:#888;">
|
||||
<span>Pipeline Flow</span>
|
||||
<span id="pt-pipeline-summary"></span>
|
||||
</div>
|
||||
<div id="pt-pipeline-bar" class="pipeline-bar"></div>
|
||||
<div id="pt-pipeline-legend" class="pipeline-legend"></div>
|
||||
</div>
|
||||
|
||||
<div class="svc-row">
|
||||
<div class="svc-item"><span class="svc-dot unknown" id="svc-downloader"></span>Downloader</div>
|
||||
<div class="svc-item"><span class="svc-dot unknown" id="svc-importer"></span>Importer</div>
|
||||
<div class="svc-item"><span class="svc-dot unknown" id="svc-transcoder"></span>Transcoder</div>
|
||||
<div class="svc-item"><span class="svc-dot unknown" id="svc-runner"></span>Runner</div>
|
||||
</div>
|
||||
|
||||
<div id="pt-gpu-panel" class="panel" style="display:none;">
|
||||
<h3 class="section-title" style="margin-bottom:8px;">GPU Status</h3>
|
||||
<div id="pt-gpu-detail" class="text-small text-muted"></div>
|
||||
</div>
|
||||
|
||||
<div id="pt-chart-container" class="panel" style="display:none;">
|
||||
<h3 class="section-title" style="margin-bottom:8px;">Pipeline Activity (24h)</h3>
|
||||
<canvas id="pt-chart" width="800" height="200" style="width:100%;height:200px;"></canvas>
|
||||
</div>
|
||||
|
||||
<div id="pt-storage" class="panel">
|
||||
<h3 class="section-title" style="margin-bottom:12px;">Pipeline Storage</h3>
|
||||
<div id="pt-storage-content" class="text-small text-muted">Loading...</div>
|
||||
</div>
|
||||
|
||||
<details id="pt-errors-panel" class="errors-panel panel">
|
||||
<summary>Recent Errors (<span id="pt-error-count">0</span>)</summary>
|
||||
<div id="pt-errors-content" style="margin-top:8px;"></div>
|
||||
</details>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/charts.js"></script>
|
||||
<script src="/static/js/peertube.js"></script>
|
||||
{% endblock %}
|
||||
41
templates/search.html
Normal file
41
templates/search.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="section-title mb-16">Semantic Search</h3>
|
||||
<form method="get" action="/search">
|
||||
<input type="text" name="q" class="search-box" placeholder="Search the knowledge base..." value="{{ query or '' }}" autofocus>
|
||||
</form>
|
||||
|
||||
{% if not query %}
|
||||
<p class="text-dim text-small" style="margin-top:8px;">Enter a query to search across all embedded concepts.</p>
|
||||
{% elif results is defined %}
|
||||
<p class="text-dim text-small mb-16">{{ results|length }} results for: <strong class="text-green">{{ query }}</strong></p>
|
||||
|
||||
{% for r in results %}
|
||||
<div class="result">
|
||||
<span class="score">{{ '%.4f'|format(r.score) }}</span>
|
||||
<div class="title">{{ r.title }}</div>
|
||||
<div class="meta">
|
||||
{{ r.citation }}
|
||||
{% if r.download_url %}
|
||||
{% if r.source_type == 'web' or (r.download_url.startswith('http') and 'files.echo6.co' not in r.download_url) %}
|
||||
| <a href="{{ r.download_url }}" target="_blank" style="color:#00bfff;text-decoration:none;">Web</a>
|
||||
{% else %}
|
||||
| <a href="{{ r.download_url }}" style="color:#00bfff;text-decoration:none;">PDF</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if r.knowledge_type %}| {{ r.knowledge_type }}{% endif %}
|
||||
{% if r.complexity %}/ {{ r.complexity }}{% endif %}
|
||||
</div>
|
||||
<div class="content-text">{{ r.summary }}</div>
|
||||
<div style="margin-top:6px;">
|
||||
{% for d in r.domains %}
|
||||
<span class="domain-tag">{{ d }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif error %}
|
||||
<p style="color:#ff4444;">Search error: {{ error }}</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
94
templates/settings/cookies.html
Normal file
94
templates/settings/cookies.html
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="section-title mb-16">YouTube Cookies</h3>
|
||||
<div class="panel">
|
||||
<div id="cookie-status" style="margin-bottom:16px;font-size:12px;color:#666;">Loading cookie status...</div>
|
||||
<div class="mb-16">
|
||||
<label class="text-dim text-xs" style="text-transform:uppercase;display:block;margin-bottom:4px;">Cookies.txt File (Netscape format)</label>
|
||||
<input type="file" id="cookie-file" accept=".txt"
|
||||
style="background:#0a0a0a;border:1px solid #333;color:#c0c0c0;padding:8px;width:100%;font-family:inherit;">
|
||||
</div>
|
||||
<button class="btn" id="cookie-btn" onclick="uploadCookies()">Upload Cookies</button>
|
||||
<span id="cookie-upload-status" style="margin-left:12px;font-size:12px;"></span>
|
||||
<div id="cookie-result" style="display:none;background:#0a0a0a;border:1px solid #222;padding:12px;margin-top:16px;font-size:11px;white-space:pre-wrap;color:#888;max-height:200px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function loadCookieStatus() {
|
||||
try {
|
||||
var resp = await fetch('/api/cookies/status');
|
||||
var data = await resp.json();
|
||||
if (resp.ok) {
|
||||
var age = data.age_hours;
|
||||
var ageStr, ageColor;
|
||||
if (age < 24) {
|
||||
ageStr = Math.round(age) + ' hours ago';
|
||||
ageColor = '#00ff41';
|
||||
} else {
|
||||
var days = Math.round(age / 24);
|
||||
ageStr = days + ' days ago';
|
||||
ageColor = days > 14 ? '#ff4444' : days > 7 ? '#ffa500' : '#00ff41';
|
||||
}
|
||||
var html = '<span style="color:' + ageColor + ';">Last updated: ' + ageStr + '</span>';
|
||||
if (data.is_stale) {
|
||||
html += ' <span style="color:#ff4444;font-weight:bold;">[STALE - cookies likely expired]</span>';
|
||||
}
|
||||
if (data.recent_rate_limits > 0) {
|
||||
html += '<br><span style="color:#ffa500;">YouTube rate limits in last 30min: ' + data.recent_rate_limits + '</span>';
|
||||
}
|
||||
html += '<br><span class="text-faint">Downloader: ' + (data.downloader_active ? 'active' : 'stopped') + '</span>';
|
||||
document.getElementById('cookie-status').innerHTML = html;
|
||||
} else {
|
||||
document.getElementById('cookie-status').innerHTML = '<span class="text-red">Could not check cookie status</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('cookie-status').innerHTML = '<span class="text-red">Error: ' + e.message + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCookies() {
|
||||
var fileInput = document.getElementById('cookie-file');
|
||||
var btn = document.getElementById('cookie-btn');
|
||||
var status = document.getElementById('cookie-upload-status');
|
||||
var result = document.getElementById('cookie-result');
|
||||
if (!fileInput.files.length) {
|
||||
status.style.color = '#ff4444';
|
||||
status.textContent = 'No file selected';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
status.style.color = '#ffa500';
|
||||
status.textContent = 'Uploading and testing cookies...';
|
||||
result.style.display = 'none';
|
||||
var formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
try {
|
||||
var resp = await fetch('/api/cookies/upload', { method: 'POST', body: formData });
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
status.style.color = '#00ff41';
|
||||
status.textContent = 'Cookies updated and verified';
|
||||
result.style.display = 'block';
|
||||
result.style.borderColor = '#00ff41';
|
||||
result.innerHTML = '<span style="color:#00ff41;">SUCCESS</span><br>' + (data.test_output || '') + '<br>Data lines: ' + data.data_lines;
|
||||
loadCookieStatus();
|
||||
} else {
|
||||
status.style.color = data.error ? '#ff4444' : '#ffa500';
|
||||
status.textContent = data.error || data.message || 'Upload issue';
|
||||
if (data.test_output) {
|
||||
result.style.display = 'block';
|
||||
result.style.borderColor = '#ff4444';
|
||||
result.textContent = data.test_output;
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
status.style.color = '#ff4444';
|
||||
status.textContent = 'Network error: ' + e.message;
|
||||
}
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
loadCookieStatus();
|
||||
</script>
|
||||
{% endblock %}
|
||||
68
templates/settings/health.html
Normal file
68
templates/settings/health.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="section-title mb-16">Service Health</h3>
|
||||
|
||||
<div id="health-grid" class="stat-grid" style="grid-template-columns:repeat(auto-fit, minmax(250px, 1fr));">
|
||||
<div class="stat-card">
|
||||
<div class="label">Qdrant</div>
|
||||
<div class="value text-small" id="h-qdrant"><span class="svc-dot unknown"></span>Checking...</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">TEI Embeddings</div>
|
||||
<div class="value text-small" id="h-tei"><span class="svc-dot unknown"></span>Checking...</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">NFS Mount</div>
|
||||
<div class="value text-small" id="h-nfs"><span class="svc-dot unknown"></span>Checking...</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Gemini API</div>
|
||||
<div class="value text-small" id="h-gemini"><span class="svc-dot unknown"></span>Checking...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title mt-24">Pipeline Status</h3>
|
||||
<div id="h-pipeline" class="panel text-small text-dim">Loading...</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function loadHealth() {
|
||||
try {
|
||||
var resp = await fetch('/api/health');
|
||||
var data = await resp.json();
|
||||
var c = data.components || {};
|
||||
|
||||
function dot(status) {
|
||||
var cls = status === 'up' ? 'active' : (status === 'configured' ? 'active' : 'inactive');
|
||||
return '<span class="svc-dot ' + cls + '"></span>';
|
||||
}
|
||||
|
||||
var q = c.qdrant || {};
|
||||
document.getElementById('h-qdrant').innerHTML = dot(q.status) + (q.status === 'up' ? 'Online — ' + RECON.fmt(q.vectors) + ' vectors' : 'Offline' + (q.error ? ' — ' + q.error : ''));
|
||||
|
||||
var t = c.tei || {};
|
||||
document.getElementById('h-tei').innerHTML = dot(t.status) + (t.status === 'up' ? 'Online' : 'Offline' + (t.error ? ' — ' + t.error : ''));
|
||||
|
||||
var n = c.nfs || {};
|
||||
document.getElementById('h-nfs').innerHTML = dot(n.status) + (n.status === 'up' ? 'Mounted' : 'Not mounted');
|
||||
|
||||
var g = c.gemini || {};
|
||||
document.getElementById('h-gemini').innerHTML = dot(g.status === 'configured' ? 'up' : 'down') + (g.status === 'configured' ? g.keys + ' keys configured' : 'No keys');
|
||||
|
||||
// Pipeline
|
||||
var p = data.pipeline || {};
|
||||
var html = '';
|
||||
Object.keys(p).forEach(function(k) {
|
||||
html += '<div style="margin:4px 0;"><span class="status status-' + k + '">' + k + '</span>: ' + p[k] + '</div>';
|
||||
});
|
||||
document.getElementById('h-pipeline').innerHTML = html || '<span class="text-dim">No pipeline data</span>';
|
||||
} catch(e) {
|
||||
document.getElementById('h-qdrant').innerHTML = '<span class="svc-dot inactive"></span>Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
RECON.startRefresh(loadHealth, 30000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
137
templates/settings/keys.html
Normal file
137
templates/settings/keys.html
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="section-title mb-16">API Keys</h3>
|
||||
<div style="margin-bottom:20px;">
|
||||
<button class="btn" onclick="validateAll()" id="btn-validate">Validate All</button>
|
||||
<button class="btn" onclick="reloadKeys()" style="margin-left:8px;">Reload from .env</button>
|
||||
<button class="btn btn-warn" onclick="restartService()" style="margin-left:8px;">Restart Service</button>
|
||||
<span id="validate-status" style="margin-left:12px;color:#666;font-size:12px;"></span>
|
||||
</div>
|
||||
<table id="keys-table">
|
||||
<tr><th>#</th><th>Key</th><th>Status</th><th>Calls</th><th>Errors</th><th>Last Used</th><th>Actions</th></tr>
|
||||
{% for k in keys_data %}
|
||||
<tr id="key-row-{{ k.index }}">
|
||||
<td>{{ k.index + 1 }}</td>
|
||||
<td class="mono text-small">{{ k.masked }}</td>
|
||||
<td>
|
||||
{% if k.valid is true %}
|
||||
<span class="text-green">Valid</span>
|
||||
{% elif k.valid is false %}
|
||||
<span class="text-red">Invalid</span>
|
||||
{% else %}
|
||||
<span class="text-dim">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ k.calls }}</td>
|
||||
<td class="{% if k.errors %}text-red{% else %}text-muted{% endif %}">{{ k.errors }}</td>
|
||||
<td class="text-dim text-xs">{{ k.last_used or '—' }}</td>
|
||||
<td>
|
||||
<button class="btn text-xs" onclick="validateKey({{ k.index }})">Test</button>
|
||||
<button class="btn btn-danger text-xs" onclick="removeKey({{ k.index }})">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<div style="margin-top:24px;border-top:1px solid #222;padding-top:16px;">
|
||||
<h4 class="text-muted" style="margin-bottom:12px;">Add Key</h4>
|
||||
<div class="flex gap-8" style="align-items:center;">
|
||||
<input type="text" id="new-key" placeholder="Paste Gemini API key..."
|
||||
style="flex:1;background:#1a1a1a;border:1px solid #333;color:#ccc;padding:8px 12px;border-radius:4px;font-family:monospace;font-size:13px;">
|
||||
<button class="btn" onclick="addKey()">Add</button>
|
||||
</div>
|
||||
<div id="add-result" style="margin-top:8px;font-size:12px;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:24px;border-top:1px solid #222;padding-top:16px;">
|
||||
<h4 class="text-muted" style="margin-bottom:12px;">Replace Key</h4>
|
||||
<div class="flex gap-8" style="align-items:center;">
|
||||
<input type="number" id="replace-index" placeholder="#" min="0" max="9"
|
||||
style="width:50px;background:#1a1a1a;border:1px solid #333;color:#ccc;padding:8px;border-radius:4px;text-align:center;">
|
||||
<input type="text" id="replace-key" placeholder="New Gemini API key..."
|
||||
style="flex:1;background:#1a1a1a;border:1px solid #333;color:#ccc;padding:8px 12px;border-radius:4px;font-family:monospace;font-size:13px;">
|
||||
<button class="btn" onclick="replaceKey()">Replace</button>
|
||||
</div>
|
||||
<div id="replace-result" style="margin-top:8px;font-size:12px;"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function validateAll() {
|
||||
document.getElementById('btn-validate').disabled = true;
|
||||
document.getElementById('validate-status').textContent = 'Validating...';
|
||||
try {
|
||||
var r = await fetch('/api/keys/validate', {method:'POST'});
|
||||
var data = await r.json();
|
||||
document.getElementById('validate-status').textContent = 'Done — ' + data.results.filter(function(r){return r.valid;}).length + '/' + data.results.length + ' valid';
|
||||
setTimeout(function() { location.reload(); }, 1000);
|
||||
} catch(e) {
|
||||
document.getElementById('validate-status').textContent = 'Error: ' + e;
|
||||
}
|
||||
document.getElementById('btn-validate').disabled = false;
|
||||
}
|
||||
|
||||
async function validateKey(idx) {
|
||||
try {
|
||||
var r = await fetch('/api/keys/' + idx + '/validate', {method:'POST'});
|
||||
var data = await r.json();
|
||||
alert('Key ' + (idx+1) + ': ' + data.message);
|
||||
location.reload();
|
||||
} catch(e) { alert('Error: ' + e); }
|
||||
}
|
||||
|
||||
async function removeKey(idx) {
|
||||
if (!confirm('Remove key ' + (idx+1) + '? Pipeline needs at least 1 key.')) return;
|
||||
try {
|
||||
var r = await fetch('/api/keys/' + idx, {method:'DELETE'});
|
||||
var data = await r.json();
|
||||
if (data.error) { alert(data.error); return; }
|
||||
location.reload();
|
||||
} catch(e) { alert('Error: ' + e); }
|
||||
}
|
||||
|
||||
async function addKey() {
|
||||
var key = document.getElementById('new-key').value.trim();
|
||||
if (!key) return;
|
||||
try {
|
||||
var r = await fetch('/api/keys', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({key:key})});
|
||||
var data = await r.json();
|
||||
if (data.error) { document.getElementById('add-result').innerHTML = '<span class="text-red">' + data.error + '</span>'; return; }
|
||||
document.getElementById('add-result').innerHTML = '<span class="text-green">Added at position ' + (data.index+1) + '</span>';
|
||||
setTimeout(function() { location.reload(); }, 1000);
|
||||
} catch(e) { document.getElementById('add-result').innerHTML = '<span class="text-red">' + e + '</span>'; }
|
||||
}
|
||||
|
||||
async function replaceKey() {
|
||||
var idx = parseInt(document.getElementById('replace-index').value) - 1;
|
||||
var key = document.getElementById('replace-key').value.trim();
|
||||
if (isNaN(idx) || !key) return;
|
||||
try {
|
||||
var r = await fetch('/api/keys/' + idx, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({key:key})});
|
||||
var data = await r.json();
|
||||
if (data.error) { document.getElementById('replace-result').innerHTML = '<span class="text-red">' + data.error + '</span>'; return; }
|
||||
document.getElementById('replace-result').innerHTML = '<span class="text-green">Replaced key ' + (idx+1) + '</span>';
|
||||
setTimeout(function() { location.reload(); }, 1000);
|
||||
} catch(e) { document.getElementById('replace-result').innerHTML = '<span class="text-red">' + e + '</span>'; }
|
||||
}
|
||||
|
||||
async function restartService() {
|
||||
if (!confirm('Restart RECON service? Pipeline will pause for ~10 seconds.')) return;
|
||||
document.getElementById('validate-status').textContent = 'Restarting...';
|
||||
try {
|
||||
await fetch('/api/service/restart', {method:'POST'});
|
||||
} catch(e) {}
|
||||
document.getElementById('validate-status').innerHTML = '<span style="color:#ff8800;">Restarting... page will reload in 10s</span>';
|
||||
setTimeout(function() { location.reload(); }, 30000);
|
||||
}
|
||||
|
||||
async function reloadKeys() {
|
||||
try {
|
||||
var r = await fetch('/api/keys/reload', {method:'POST'});
|
||||
var data = await r.json();
|
||||
alert('Reloaded ' + data.count + ' key(s) from .env');
|
||||
location.reload();
|
||||
} catch(e) { alert('Error: ' + e); }
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
97
templates/settings/vpn.html
Normal file
97
templates/settings/vpn.html
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3 class="section-title mb-16">NordVPN</h3>
|
||||
<div class="panel">
|
||||
<div id="vpn-status" style="margin-bottom:16px;font-size:12px;color:#666;">Loading VPN status...</div>
|
||||
<div class="flex gap-8" style="flex-wrap:wrap;margin-bottom:12px;">
|
||||
<button class="btn" onclick="vpnRotate()" id="vpn-rotate-btn">Rotate</button>
|
||||
<button class="btn" onclick="vpnDisconnect()" id="vpn-disconnect-btn">Disconnect</button>
|
||||
<select id="vpn-country" style="background:#0a0a0a;border:1px solid #333;color:#c0c0c0;padding:6px;font-family:inherit;font-size:12px;">
|
||||
<option value="United_States">United States</option>
|
||||
<option value="Canada">Canada</option>
|
||||
<option value="United_Kingdom">United Kingdom</option>
|
||||
<option value="Germany">Germany</option>
|
||||
<option value="Netherlands">Netherlands</option>
|
||||
<option value="Sweden">Sweden</option>
|
||||
</select>
|
||||
<button class="btn" onclick="vpnConnect()" id="vpn-connect-btn">Connect</button>
|
||||
</div>
|
||||
<span id="vpn-action-status" style="font-size:12px;"></span>
|
||||
<details style="margin-top:16px;">
|
||||
<summary class="text-faint" style="cursor:pointer;font-size:11px;">Setup (one-time)</summary>
|
||||
<div style="margin-top:8px;">
|
||||
<input type="password" id="vpn-token" placeholder="NordVPN token"
|
||||
style="background:#0a0a0a;border:1px solid #333;color:#c0c0c0;padding:6px;width:300px;font-family:inherit;font-size:12px;">
|
||||
<button class="btn" onclick="vpnLogin()">Login</button>
|
||||
<span id="vpn-login-status" style="font-size:11px;margin-left:8px;"></span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function loadVpnStatus() {
|
||||
try {
|
||||
var resp = await fetch('/api/vpn/status');
|
||||
var data = await resp.json();
|
||||
if (resp.ok) {
|
||||
var dot = data.connected ? '<span style="color:#00ff41;">●</span>' : '<span style="color:#ff4444;">●</span>';
|
||||
var html = dot + ' ' + (data.connected ? 'Connected' : 'Disconnected');
|
||||
if (data.connected) {
|
||||
html += ' — <span style="color:#00ff41;">' + data.country + '</span>';
|
||||
html += ' <span class="text-faint">(' + data.ip + ')</span>';
|
||||
}
|
||||
if (data.rotations_today > 0) {
|
||||
html += '<br><span class="text-faint">Rotations today: ' + data.rotations_today + '</span>';
|
||||
}
|
||||
document.getElementById('vpn-status').innerHTML = html;
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('vpn-status').innerHTML = '<span class="text-red">Error: ' + e.message + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function vpnAction(url, opts, statusEl) {
|
||||
var el = document.getElementById(statusEl || 'vpn-action-status');
|
||||
el.style.color = '#ffa500';
|
||||
el.textContent = 'Working...';
|
||||
try {
|
||||
var resp = await fetch(url, opts);
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
el.style.color = '#00ff41';
|
||||
el.textContent = data.country ? (data.country + ' (' + data.ip + ')') : (data.message || 'Done');
|
||||
} else {
|
||||
el.style.color = '#ff4444';
|
||||
el.textContent = data.error || data.message || 'Failed';
|
||||
}
|
||||
loadVpnStatus();
|
||||
} catch(e) {
|
||||
el.style.color = '#ff4444';
|
||||
el.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function vpnRotate() { vpnAction('/api/vpn/rotate', {method:'POST'}); }
|
||||
function vpnDisconnect() { vpnAction('/api/vpn/disconnect', {method:'POST'}); }
|
||||
function vpnConnect() {
|
||||
var country = document.getElementById('vpn-country').value;
|
||||
vpnAction('/api/vpn/connect', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({country: country})
|
||||
});
|
||||
}
|
||||
function vpnLogin() {
|
||||
var token = document.getElementById('vpn-token').value;
|
||||
if (!token) return;
|
||||
vpnAction('/api/vpn/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({token: token})
|
||||
}, 'vpn-login-status');
|
||||
}
|
||||
|
||||
loadVpnStatus();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue