2.0.0-rc2: Achievements Refactored & Admin improved.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% block admin_title %}Admin{% endblock %} - YRTV{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set admin_nav = [
|
||||
('admin.dashboard', '总览', 'Overview'),
|
||||
('admin.import_match', '比赛导入', 'Import'),
|
||||
('admin.jobs', '作业中心', 'Jobs'),
|
||||
('admin.data_integrity', '数据质量', 'Integrity'),
|
||||
('admin.sql_runner', '查询控制台', 'Query'),
|
||||
('admin.system', '系统信息', 'System')
|
||||
] %}
|
||||
<div class="min-h-[calc(100vh-8rem)] px-3 sm:px-0">
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 shadow-xl dark:border-slate-700 dark:bg-slate-950">
|
||||
<div class="grid min-h-[760px] grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)]">
|
||||
<aside class="border-b border-slate-200 bg-slate-950 text-slate-300 lg:border-b-0 lg:border-r lg:border-slate-800">
|
||||
<div class="flex items-center justify-between px-5 py-5">
|
||||
<div>
|
||||
<div class="text-xs font-black uppercase tracking-[0.25em] text-yrtv-400">YRTV</div>
|
||||
<div class="mt-1 text-lg font-black text-white">Operations</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-amber-400/10 px-2 py-1 text-[10px] font-bold uppercase text-amber-300">Beta</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex gap-1 overflow-x-auto px-3 pb-4 lg:block lg:space-y-1 lg:overflow-visible">
|
||||
{% for endpoint, label, english in admin_nav %}
|
||||
{% set active = request.endpoint == endpoint %}
|
||||
<a href="{{ url_for(endpoint) }}"
|
||||
class="group flex min-w-max items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold transition
|
||||
{% if active %}bg-yrtv-600 text-white shadow-lg shadow-yrtv-950/30
|
||||
{% else %}text-slate-400 hover:bg-slate-800 hover:text-white{% endif %}">
|
||||
<span class="flex h-7 w-7 items-center justify-center rounded-lg {% if active %}bg-white/15{% else %}bg-slate-800 group-hover:bg-slate-700{% endif %}">
|
||||
{{ loop.index|string }}
|
||||
</span>
|
||||
<span>
|
||||
<span class="block">{{ label }}</span>
|
||||
<span class="block text-[10px] font-medium uppercase tracking-wider opacity-60">{{ english }}</span>
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="hidden border-t border-slate-800 p-4 lg:block">
|
||||
<a href="{{ url_for('main.index') }}" class="block rounded-lg px-3 py-2 text-sm font-bold text-slate-400 hover:bg-slate-800 hover:text-white">返回站点</a>
|
||||
<a href="{{ url_for('admin.logout') }}" class="mt-1 block rounded-lg px-3 py-2 text-sm font-bold text-red-400 hover:bg-red-950/40">退出后台</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0">
|
||||
<header class="border-b border-slate-200 bg-white/90 px-5 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-900/90">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-black text-slate-900 dark:text-white">{% block page_title %}管理后台{% endblock %}</h1>
|
||||
<p class="mt-0.5 text-xs text-slate-500">{% block page_subtitle %}YRTV 数据运营控制台{% endblock %}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ url_for('admin.data_integrity') }}" class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-bold text-slate-600 hover:border-emerald-300 hover:text-emerald-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">系统状态</a>
|
||||
<a href="{{ url_for('main.index') }}" class="rounded-lg bg-slate-900 px-3 py-2 text-xs font-bold text-white hover:bg-slate-700 dark:bg-white dark:text-slate-900">打开前台</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="p-4 sm:p-6 lg:p-8">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-2">
|
||||
{% for category, message in messages %}
|
||||
<div class="rounded-xl border px-4 py-3 text-sm font-medium
|
||||
{% if category == 'success' %}border-emerald-200 bg-emerald-50 text-emerald-800
|
||||
{% elif category == 'warning' %}border-amber-200 bg-amber-50 text-amber-800
|
||||
{% else %}border-red-200 bg-red-50 text-red-800{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block admin_content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,57 +1,147 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">管理后台 (Admin Dashboard)</h2>
|
||||
<a href="{{ url_for('admin.logout') }}" class="text-red-600 hover:text-red-800">Logout</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- ETL Controls -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">数据管线 (ETL)</h3>
|
||||
<div class="space-y-2">
|
||||
<a href="{{ url_for('admin.import_match') }}" class="block w-full text-center bg-yrtv-600 text-white py-2 px-4 rounded hover:bg-yrtv-500">上传并导入比赛</a>
|
||||
<button onclick="triggerEtl()" class="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700">运行完整 L1 → L2 → L3</button>
|
||||
</div>
|
||||
<div id="etlResult" class="mt-4 text-sm text-gray-600 dark:text-gray-400"></div>
|
||||
{% block admin_title %}运营总览{% endblock %}
|
||||
{% block page_title %}运营总览{% endblock %}
|
||||
{% block page_subtitle %}数据规模、流水线状态与最近比赛一屏掌握{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
{% set health_color = 'emerald' if integrity.overall_status == 'pass' else ('amber' if integrity.overall_status == 'warn' else 'red') %}
|
||||
<div class="space-y-7">
|
||||
<section class="grid grid-cols-2 gap-3 xl:grid-cols-5">
|
||||
{% for label, value, note in [
|
||||
('比赛总量', metrics.matches, 'L2 matches'),
|
||||
('Active Roster', metrics.roster_players, roster_versions|string + ' roster version'),
|
||||
('赛后报告', metrics.reports, 'L3 reports'),
|
||||
('周期奖项', metrics.awards, 'Awards generated'),
|
||||
('备份占用', metrics.backup_size, integrity.counts.backup_sets|string + ' snapshots')
|
||||
] %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-slate-400">{{ label }}</div>
|
||||
<div class="mt-2 text-3xl font-black text-slate-900 dark:text-white">{{ value }}</div>
|
||||
<div class="mt-1 truncate text-[11px] text-slate-400">{{ note }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools -->
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4">工具箱</h3>
|
||||
<div class="space-y-2">
|
||||
<a href="{{ url_for('admin.data_integrity') }}" class="block w-full text-center bg-emerald-600 text-white py-2 px-4 rounded hover:bg-emerald-700">数据完整性中心</a>
|
||||
<a href="{{ url_for('admin.sql_runner') }}" class="block w-full text-center bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">SQL Runner</a>
|
||||
<a href="{{ url_for('wiki.index') }}" class="block w-full text-center bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">Manage Wiki</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<section class="overflow-hidden rounded-2xl bg-gradient-to-br from-slate-950 via-slate-900 to-yrtv-950 p-6 text-white shadow-xl xl:col-span-2">
|
||||
<div class="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-black uppercase tracking-[0.22em] text-yrtv-300">Data Pipeline</div>
|
||||
<h2 class="mt-2 text-2xl font-black">完整数据流水线</h2>
|
||||
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-300">运行前自动备份 L1/L2/L3,串行构建并验证 {{ integrity.checks|length }} 项数据质量规则。失败时自动恢复。</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/10 px-4 py-3 text-right">
|
||||
<div class="text-xs uppercase text-slate-400">Running</div>
|
||||
<div class="text-2xl font-black {% if job_summary.running %}text-amber-300{% else %}text-emerald-300{% endif %}">{{ job_summary.running }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
<a href="{{ url_for('admin.import_match') }}" class="rounded-xl bg-yrtv-500 px-5 py-3 text-sm font-black text-white hover:bg-yrtv-400">导入新比赛</a>
|
||||
<button id="pipeline-button" onclick="triggerPipeline()" class="rounded-xl bg-white px-5 py-3 text-sm font-black text-slate-900 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50" {% if job_summary.running %}disabled{% endif %}>运行完整流水线</button>
|
||||
<a href="{{ url_for('admin.jobs') }}" class="rounded-xl border border-white/20 px-5 py-3 text-sm font-bold text-white hover:bg-white/10">查看所有作业</a>
|
||||
</div>
|
||||
<p id="pipeline-message" class="mt-3 text-xs text-slate-400"></p>
|
||||
</section>
|
||||
|
||||
<a href="{{ url_for('admin.data_integrity') }}" class="group rounded-2xl border border-{{ health_color }}-200 bg-{{ health_color }}-50 p-6 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-{{ health_color }}-900/50 dark:bg-{{ health_color }}-950/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-black uppercase tracking-widest text-{{ health_color }}-600">Data Health</div>
|
||||
<span class="rounded-full bg-{{ health_color }}-100 px-3 py-1 text-xs font-black uppercase text-{{ health_color }}-700">{{ integrity.overall_status }}</span>
|
||||
</div>
|
||||
<div class="mt-5 flex items-end gap-3">
|
||||
<div class="text-5xl font-black text-{{ health_color }}-700">{{ integrity.totals.pass }}</div>
|
||||
<div class="pb-1 text-sm text-{{ health_color }}-700">项检查通过</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="rounded-lg bg-white/70 p-2 text-slate-600">警告 <b>{{ integrity.totals.warn }}</b></div>
|
||||
<div class="rounded-lg bg-white/70 p-2 text-slate-600">失败 <b>{{ integrity.totals.fail }}</b></div>
|
||||
</div>
|
||||
<div class="mt-4 text-xs font-bold text-{{ health_color }}-700 group-hover:underline">查看完整报告 →</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900 xl:col-span-2">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<div>
|
||||
<h2 class="font-black text-slate-900 dark:text-white">最近作业</h2>
|
||||
<p class="text-xs text-slate-400">成功 {{ job_summary.succeeded }} · 失败 {{ job_summary.failed }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.jobs') }}" class="text-xs font-bold text-yrtv-600 hover:underline">作业中心</a>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{% for job in jobs %}
|
||||
<a href="{{ url_for('admin.jobs', job_id=job.id) }}" class="flex items-center gap-4 px-5 py-3.5 hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<span class="h-2.5 w-2.5 rounded-full {% if job.status == 'succeeded' %}bg-emerald-500{% elif job.status == 'failed' %}bg-red-500{% elif job.status == 'running' %}bg-amber-400 animate-pulse{% else %}bg-slate-300{% endif %}"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-bold text-slate-800 dark:text-slate-200">#{{ job.id }} {{ job.job_type }}</div>
|
||||
<div class="truncate text-xs text-slate-400">{{ job.message or job.current_stage or '等待执行' }}</div>
|
||||
</div>
|
||||
<div class="text-right text-xs text-slate-400">
|
||||
<div class="font-bold uppercase">{{ job.status }}</div>
|
||||
<div>{{ '%.2fs'|format(job.duration_seconds) if job.duration_seconds is not none else job.progress|string + '%' }}</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="px-5 py-10 text-center text-sm text-slate-400">暂无流水线作业</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="text-xs font-black uppercase tracking-widest text-slate-400">Latest Match</div>
|
||||
{% if latest_match %}
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="rounded-lg px-2.5 py-1 text-xs font-black {% if latest_match.is_win %}bg-emerald-100 text-emerald-700{% else %}bg-red-100 text-red-600{% endif %}">{{ 'WIN' if latest_match.is_win else 'LOSS' }}</span>
|
||||
<span class="text-xs text-slate-400">{{ latest_match.date_text }}</span>
|
||||
</div>
|
||||
<div class="mt-4 text-2xl font-black text-slate-900 dark:text-white">{{ latest_match.map_name }}</div>
|
||||
<div class="mt-1 text-sm text-slate-500">Team Rating {{ '%.2f'|format(latest_match.team_avg_rating) }}</div>
|
||||
<p class="mt-4 line-clamp-3 text-xs leading-5 text-slate-500">{{ latest_match.summary_text }}</p>
|
||||
<a href="{{ url_for('matches.detail', match_id=latest_match.match_id) }}" class="mt-4 block text-xs font-black text-yrtv-600 hover:underline">打开赛后报告 →</a>
|
||||
{% else %}
|
||||
<div class="mt-8 text-sm text-slate-400">暂无比赛数据</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{% for endpoint, title, note in [
|
||||
('admin.sql_runner', '查询数据', '只读 SQL Console'),
|
||||
('admin.system', '系统信息', '数据库与备份'),
|
||||
('teams.performance', '战队履历', 'Roster & Lineup'),
|
||||
('awards.index', '荣誉中心', '周期最佳')
|
||||
] %}
|
||||
<a href="{{ url_for(endpoint) }}" class="rounded-xl border border-slate-200 bg-white p-4 transition hover:border-yrtv-300 hover:shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="font-black text-slate-900 dark:text-white">{{ title }}</div>
|
||||
<div class="mt-1 text-xs text-slate-400">{{ note }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
function triggerEtl() {
|
||||
const resultDiv = document.getElementById('etlResult');
|
||||
resultDiv.innerText = "正在创建后台流水线...";
|
||||
|
||||
fetch("{{ url_for('admin.trigger_etl') }}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = "{{ url_for('admin.import_match') }}?job_id=" + data.job_id;
|
||||
} else {
|
||||
resultDiv.innerText = data.error || "启动失败";
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
resultDiv.innerText = "Error: " + err;
|
||||
});
|
||||
async function triggerPipeline() {
|
||||
const button = document.getElementById('pipeline-button');
|
||||
const message = document.getElementById('pipeline-message');
|
||||
button.disabled = true;
|
||||
message.textContent = '正在创建后台流水线...';
|
||||
try {
|
||||
const response = await fetch("{{ url_for('admin.trigger_etl') }}", {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': "{{ csrf_token() }}"}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) throw new Error(data.error || '启动失败');
|
||||
window.location.href = "{{ url_for('admin.jobs') }}?job_id=" + data.job_id;
|
||||
} catch (error) {
|
||||
message.textContent = error.message;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,84 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}数据完整性 - YRTV{% endblock %}
|
||||
{% block admin_title %}数据质量{% endblock %}
|
||||
{% block page_title %}数据质量中心{% endblock %}
|
||||
{% block page_subtitle %}数据库健康、引用完整性、Mart 覆盖与运行治理{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block admin_content %}
|
||||
{% set status_styles = {
|
||||
'pass': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
'warn': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
'fail': 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||
'pass': 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
'warn': 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
'fail': 'bg-red-100 text-red-700 border-red-200'
|
||||
} %}
|
||||
<div class="space-y-6 px-4 sm:px-0">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">数据完整性中心</h1>
|
||||
<span class="rounded-full px-3 py-1 text-xs font-semibold uppercase {{ status_styles[report.overall_status] }}">
|
||||
{{ report.overall_status }}
|
||||
</span>
|
||||
<div class="space-y-6">
|
||||
<section class="overflow-hidden rounded-2xl bg-gradient-to-r {% if report.overall_status == 'pass' %}from-emerald-600 to-teal-700{% elif report.overall_status == 'warn' %}from-amber-500 to-orange-600{% else %}from-red-600 to-rose-700{% endif %} p-6 text-white shadow-xl">
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-black uppercase tracking-[0.2em] text-white/70">Integrity Report</div>
|
||||
<div class="mt-2 flex items-baseline gap-3">
|
||||
<div class="text-5xl font-black">{{ report.totals.pass }}</div>
|
||||
<div class="text-lg font-bold">项检查通过</div>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-white/70">生成于 {{ report.generated_at }}</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="rounded-xl bg-white/10 px-5 py-3 text-center"><div class="text-2xl font-black">{{ report.totals.warn }}</div><div class="text-[10px] uppercase text-white/70">Warnings</div></div>
|
||||
<div class="rounded-xl bg-white/10 px-5 py-3 text-center"><div class="text-2xl font-black">{{ report.totals.fail }}</div><div class="text-[10px] uppercase text-white/70">Failures</div></div>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
校验时间:{{ report.generated_at }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.data_integrity', format='json') }}"
|
||||
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800">
|
||||
JSON
|
||||
</a>
|
||||
<a href="{{ url_for('admin.data_integrity') }}"
|
||||
class="rounded-lg bg-yrtv-600 px-4 py-2 text-sm font-medium text-white hover:bg-yrtv-500">
|
||||
重新校验
|
||||
</a>
|
||||
<div class="mt-6 flex gap-3">
|
||||
<a href="{{ url_for('admin.data_integrity') }}" class="rounded-xl bg-white px-4 py-2 text-sm font-black text-slate-900">重新检查</a>
|
||||
<a href="{{ url_for('admin.data_integrity', format='json') }}" target="_blank" class="rounded-xl border border-white/30 px-4 py-2 text-sm font-bold text-white">JSON 报告</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{% for status, label in [('pass', '通过'), ('warn', '警告'), ('fail', '失败')] %}
|
||||
<div class="rounded-xl bg-white p-4 shadow dark:bg-slate-800">
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400">{{ label }}</div>
|
||||
<div class="mt-1 text-3xl font-bold {% if status == 'pass' %}text-emerald-600{% elif status == 'warn' %}text-amber-600{% else %}text-red-600{% endif %}">
|
||||
{{ report.totals[status] }}
|
||||
</div>
|
||||
<section class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{% for label, value in [
|
||||
('Matches', report.counts.matches),
|
||||
('Players', report.counts.players),
|
||||
('Rounds', report.counts.rounds),
|
||||
('Events', report.counts.events),
|
||||
('Reports', report.counts.l3_match_reports),
|
||||
('Awards', report.counts.l3_awards)
|
||||
] %}
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="text-[10px] font-black uppercase tracking-wider text-slate-400">{{ label }}</div>
|
||||
<div class="mt-1 text-2xl font-black text-slate-900 dark:text-white">{{ '{:,}'.format(value or 0) }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rounded-xl bg-white p-5 shadow dark:bg-slate-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-slate-900 dark:text-white">数据规模</h2>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{% for key, value in report.counts.items() %}
|
||||
<div class="rounded-lg bg-slate-50 p-3 dark:bg-slate-900/60">
|
||||
<div class="truncate text-xs uppercase tracking-wide text-slate-500">{{ key|replace('_', ' ') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-slate-900 dark:text-white">{{ "{:,}".format(value) }}</div>
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_300px]">
|
||||
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<h2 class="font-black text-slate-900 dark:text-white">检查明细</h2>
|
||||
<p class="text-xs text-slate-400">失败优先,其次警告,最后通过项</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl bg-white shadow dark:bg-slate-800">
|
||||
<div class="border-b border-slate-200 px-5 py-4 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">校验项目</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{% for check in report.checks %}
|
||||
<div class="flex flex-col gap-2 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="font-medium text-slate-900 dark:text-white">{{ check.name }}</div>
|
||||
<div class="mt-1 text-sm text-slate-500 dark:text-slate-400">{{ check.detail }}</div>
|
||||
</div>
|
||||
<span class="self-start rounded-full px-3 py-1 text-xs font-semibold uppercase sm:self-center {{ status_styles[check.status] }}">
|
||||
{{ check.status }}
|
||||
</span>
|
||||
<div class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{% for target_status in ['fail', 'warn', 'pass'] %}
|
||||
{% for check in report.checks if check.status == target_status %}
|
||||
<div class="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-slate-800 dark:text-slate-200">{{ check.name }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-slate-400">{{ check.detail }}</div>
|
||||
</div>
|
||||
<span class="self-start rounded-full border px-2.5 py-1 text-[10px] font-black uppercase sm:self-center {{ status_styles[check.status] }}">{{ check.status }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="text-sm font-medium text-yrtv-600 hover:text-yrtv-500">
|
||||
返回管理后台
|
||||
</a>
|
||||
<aside class="space-y-4">
|
||||
{% for title, entries in [
|
||||
('Roster & Team', [
|
||||
('Active roster', report.counts.active_roster),
|
||||
('Roster versions', report.counts.roster_versions),
|
||||
('Duo marts', report.counts.l3_duos),
|
||||
('Lineup marts', report.counts.l3_lineups)
|
||||
]),
|
||||
('Profile Marts', [
|
||||
('History', report.counts.l3_history),
|
||||
('Periods', report.counts.l3_periods),
|
||||
('Records', report.counts.l3_records),
|
||||
('Record events', report.counts.l3_record_events)
|
||||
]),
|
||||
('Operations', [
|
||||
('ETL jobs', report.counts.etl_jobs),
|
||||
('Imports', report.counts.match_imports),
|
||||
('Backups', report.counts.backup_sets),
|
||||
('Web schema', report.counts.web_schema_version)
|
||||
])
|
||||
] %}
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-black uppercase tracking-wider text-slate-400">{{ title }}</h3>
|
||||
<dl class="mt-3 space-y-2">
|
||||
{% for label, value in entries %}
|
||||
<div class="flex items-center justify-between"><dt class="text-sm text-slate-500">{{ label }}</dt><dd class="font-mono text-sm font-black text-slate-800 dark:text-slate-200">{{ '{:,}'.format(value or 0) }}</dd></div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,153 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}比赛导入 - YRTV{% endblock %}
|
||||
{% block admin_title %}比赛导入{% endblock %}
|
||||
{% block page_title %}比赛导入{% endblock %}
|
||||
{% block page_subtitle %}校验网络抓包并启动可回滚的 L1 → L2 → L3 流水线{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6 px-4 sm:px-0">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="space-y-2">
|
||||
{% for category, message in messages %}
|
||||
<div class="rounded-lg px-4 py-3 text-sm {% if category == 'success' %}bg-emerald-100 text-emerald-800{% elif category == 'warning' %}bg-amber-100 text-amber-800{% else %}bg-red-100 text-red-800{% endif %}">
|
||||
{{ message }}
|
||||
{% block admin_content %}
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-black text-slate-900 dark:text-white">上传 iframe_network.json</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">系统自动提取比赛 ID、计算 SHA256、检查必要接口并拒绝重复数据。</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-yrtv-50 px-3 py-1 text-xs font-black text-yrtv-700">Max {{ '%.0f MB'|format(config.MAX_CONTENT_LENGTH / 1024 / 1024) }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">比赛数据导入</h1>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
上传完整的 iframe_network.json,系统会自动识别比赛 ID,并执行 L1 → L2 → L3。
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="text-sm font-medium text-yrtv-600 hover:text-yrtv-500">
|
||||
返回管理后台
|
||||
</a>
|
||||
</div>
|
||||
<form method="POST" enctype="multipart/form-data" class="mt-6 space-y-5">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<label id="drop-zone" class="group flex min-h-[260px] cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50 px-6 text-center transition hover:border-yrtv-400 hover:bg-yrtv-50/40 dark:border-slate-700 dark:bg-slate-950/40">
|
||||
<input id="capture-input" type="file" name="capture" accept=".json,application/json" required class="sr-only">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-3xl shadow-sm transition group-hover:scale-105 dark:bg-slate-800">⇧</div>
|
||||
<div id="file-name" class="mt-4 font-black text-slate-800 dark:text-white">点击选择 JSON 文件</div>
|
||||
<div class="mt-2 text-sm text-slate-400">或将 iframe_network.json 拖到这里</div>
|
||||
<div class="mt-4 rounded-full bg-white px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-400 shadow-sm dark:bg-slate-800">UTF-8 · One Match · Network Capture</div>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="rounded-xl bg-white p-6 shadow dark:bg-slate-800">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">上传抓包</h2>
|
||||
<form method="POST" enctype="multipart/form-data" class="mt-5 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
iframe_network.json
|
||||
</label>
|
||||
<input type="file" name="capture" accept=".json,application/json" required
|
||||
class="mt-2 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-yrtv-50 file:px-4 file:py-2 file:font-medium file:text-yrtv-700 hover:file:bg-yrtv-100 dark:text-slate-300">
|
||||
</div>
|
||||
<label class="flex items-start gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<input type="checkbox" name="replace" value="1" class="mt-1 rounded border-slate-300 text-yrtv-600">
|
||||
<span>允许替换已存在但内容不同的比赛。流水线失败时会自动恢复数据库。</span>
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<label class="flex items-start gap-3">
|
||||
<input type="checkbox" name="replace" value="1" class="mt-0.5 rounded border-amber-300 text-amber-600">
|
||||
<span>
|
||||
<span class="block text-sm font-black text-amber-800 dark:text-amber-300">允许替换内容不同的已有比赛</span>
|
||||
<span class="mt-1 block text-xs leading-5 text-amber-700/80 dark:text-amber-400">仅在确认原始抓包需要修复时使用。相同内容永远拒绝重复导入。</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="submit"
|
||||
class="w-full rounded-lg bg-yrtv-600 px-4 py-2.5 font-medium text-white hover:bg-yrtv-500">
|
||||
校验并开始导入
|
||||
</button>
|
||||
</form>
|
||||
<div class="mt-5 rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
|
||||
直接重复上传会被拒绝。每次流水线运行前都会备份 L1、L2、L3,并执行后置完整性检查。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white p-6 shadow dark:bg-slate-800 lg:col-span-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">作业状态</h2>
|
||||
{% if selected_job %}
|
||||
<span id="job-status" class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase text-slate-700 dark:bg-slate-700 dark:text-slate-200">
|
||||
{{ selected_job.status }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if selected_job %}
|
||||
<div class="mt-5">
|
||||
<div class="flex justify-between text-sm text-slate-600 dark:text-slate-300">
|
||||
<span id="job-stage">{{ selected_job.current_stage or 'queued' }}</span>
|
||||
<span id="job-progress-label">{{ selected_job.progress }}%</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div id="job-progress" class="h-full bg-yrtv-500 transition-all" style="width: {{ selected_job.progress }}%"></div>
|
||||
</div>
|
||||
<p id="job-message" class="mt-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ selected_job.message or '等待执行' }}
|
||||
</p>
|
||||
<pre id="job-log" class="mt-4 max-h-96 overflow-auto whitespace-pre-wrap rounded-lg bg-slate-950 p-4 text-xs text-slate-200">{{ selected_job.log_text }}</pre>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<button type="submit" class="flex-1 rounded-xl bg-yrtv-600 px-5 py-3 text-sm font-black text-white shadow-lg shadow-yrtv-200 hover:bg-yrtv-500">校验并开始导入</button>
|
||||
<a href="{{ url_for('admin.jobs') }}" class="rounded-xl border border-slate-200 px-5 py-3 text-center text-sm font-bold text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300">查看作业中心</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-8 rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600">
|
||||
上传比赛或从下方选择历史作业。
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="overflow-hidden rounded-xl bg-white shadow dark:bg-slate-800">
|
||||
<div class="border-b border-slate-200 px-5 py-4 dark:border-slate-700">
|
||||
<h2 class="font-semibold text-slate-900 dark:text-white">最近作业</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900/50">
|
||||
<tr>
|
||||
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">ID</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">类型</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">比赛</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">状态</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">阶段</th>
|
||||
<th class="px-5 py-3 text-left text-xs font-semibold uppercase text-slate-500">耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{% for job in jobs %}
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40">
|
||||
<td class="px-5 py-3 text-sm">
|
||||
<a href="{{ url_for('admin.import_match', job_id=job.id) }}" class="font-medium text-yrtv-600">#{{ job.id }}</a>
|
||||
</td>
|
||||
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ job.job_type }}</td>
|
||||
<td class="px-5 py-3 text-sm font-mono text-slate-600 dark:text-slate-300">{{ job.match_id or '-' }}</td>
|
||||
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ job.status }}</td>
|
||||
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ job.current_stage or '-' }}</td>
|
||||
<td class="px-5 py-3 text-sm text-slate-600 dark:text-slate-300">{{ '%.2fs'|format(job.duration_seconds) if job.duration_seconds is not none else '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="px-5 py-8 text-center text-sm text-slate-500">暂无作业</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="space-y-6">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<h2 class="font-black text-slate-900 dark:text-white">导入流程</h2>
|
||||
<ol class="mt-4 space-y-3">
|
||||
{% for title, detail in [
|
||||
('结构校验', 'JSON、比赛 ID、match/round 接口'),
|
||||
('内容去重', 'SHA256 与 L1、待处理文件比对'),
|
||||
('安全备份', '保存 L1/L2/L3 回滚快照'),
|
||||
('分层构建', '串行执行 L1 → L2 → L3'),
|
||||
('质量验收', '玩家数、回合与自动完整性规则')
|
||||
] %}
|
||||
<li class="flex gap-3">
|
||||
<span class="flex h-6 w-6 flex-none items-center justify-center rounded-full bg-yrtv-100 text-xs font-black text-yrtv-700">{{ loop.index }}</span>
|
||||
<span><b class="block text-sm text-slate-700 dark:text-slate-200">{{ title }}</b><span class="text-xs text-slate-400">{{ detail }}</span></span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<h2 class="font-black text-slate-900 dark:text-white">最近导入作业</h2>
|
||||
<a href="{{ url_for('admin.jobs') }}" class="text-xs font-bold text-yrtv-600">全部</a>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{% for job in jobs %}
|
||||
<a href="{{ url_for('admin.jobs', job_id=job.id) }}" class="flex items-center justify-between px-5 py-3 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-bold text-slate-700 dark:text-slate-200">#{{ job.id }} {{ job.match_id or job.job_type }}</div>
|
||||
<div class="text-xs text-slate-400">{{ job.current_stage or job.status }}</div>
|
||||
</div>
|
||||
<span class="ml-3 rounded-full px-2 py-1 text-[10px] font-black uppercase {% if job.status == 'succeeded' %}bg-emerald-100 text-emerald-700{% elif job.status == 'failed' %}bg-red-100 text-red-700{% else %}bg-amber-100 text-amber-700{% endif %}">{{ job.status }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="px-5 py-8 text-center text-sm text-slate-400">暂无作业</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if selected_job %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
const jobId = {{ selected_job.id }};
|
||||
let pollTimer = null;
|
||||
|
||||
async function refreshJob() {
|
||||
const response = await fetch(`/admin/api/jobs/${jobId}`);
|
||||
if (!response.ok) return;
|
||||
const job = await response.json();
|
||||
document.getElementById('job-status').textContent = job.status;
|
||||
document.getElementById('job-stage').textContent = job.current_stage || job.status;
|
||||
document.getElementById('job-progress-label').textContent = `${job.progress}%`;
|
||||
document.getElementById('job-progress').style.width = `${job.progress}%`;
|
||||
document.getElementById('job-message').textContent = job.message || '';
|
||||
const log = document.getElementById('job-log');
|
||||
log.textContent = job.log_text || '';
|
||||
log.scrollTop = log.scrollHeight;
|
||||
if (job.status === 'succeeded' || job.status === 'failed') {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
pollTimer = setInterval(refreshJob, 1500);
|
||||
refreshJob();
|
||||
const input = document.getElementById('capture-input');
|
||||
const fileName = document.getElementById('file-name');
|
||||
input.addEventListener('change', () => {
|
||||
fileName.textContent = input.files.length ? input.files[0].name : '点击选择 JSON 文件';
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block admin_title %}作业中心{% endblock %}
|
||||
{% block page_title %}作业中心{% endblock %}
|
||||
{% block page_subtitle %}跟踪导入、全量构建、失败日志与执行耗时{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="space-y-6">
|
||||
<section class="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
{% for key, label in [('total','全部'),('running','运行中'),('queued','排队'),('succeeded','成功'),('failed','失败')] %}
|
||||
<a href="{{ url_for('admin.jobs', status=key if key != 'total' else None) }}"
|
||||
class="rounded-xl border bg-white p-4 shadow-sm transition hover:border-yrtv-300 dark:bg-slate-900
|
||||
{% if status == key or (key == 'total' and not status) %}border-yrtv-400 ring-2 ring-yrtv-100{% else %}border-slate-200 dark:border-slate-800{% endif %}">
|
||||
<div class="text-xs font-bold uppercase text-slate-400">{{ label }}</div>
|
||||
<div class="mt-1 text-2xl font-black text-slate-900 dark:text-white">{{ summary[key] }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex flex-col gap-3 border-b border-slate-100 px-5 py-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
|
||||
<div>
|
||||
<h2 class="font-black text-slate-900 dark:text-white">执行记录</h2>
|
||||
<p class="text-xs text-slate-400">最多显示最近 50 条</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.import_match') }}" class="rounded-lg bg-yrtv-600 px-3 py-2 text-xs font-bold text-white">新建导入</a>
|
||||
<a href="{{ url_for('admin.jobs') }}" class="rounded-lg border border-slate-200 px-3 py-2 text-xs font-bold text-slate-600 dark:border-slate-700 dark:text-slate-300">清除筛选</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-slate-50 text-left text-[10px] font-black uppercase tracking-wider text-slate-400 dark:bg-slate-950/50">
|
||||
<tr><th class="px-5 py-3">Job</th><th class="px-4 py-3">Match</th><th class="px-4 py-3">Stage</th><th class="px-4 py-3">Status</th><th class="px-4 py-3 text-right">Duration</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{% for job in jobs %}
|
||||
<tr class="{% if selected_job and selected_job.id == job.id %}bg-yrtv-50 dark:bg-yrtv-950/20{% endif %} hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<td class="px-5 py-3">
|
||||
<a href="{{ url_for('admin.jobs', job_id=job.id, status=status, type=job_type) }}" class="font-black text-yrtv-600">#{{ job.id }}</a>
|
||||
<div class="text-xs text-slate-400">{{ job.job_type }}</div>
|
||||
</td>
|
||||
<td class="max-w-[180px] truncate px-4 py-3 font-mono text-xs text-slate-500">{{ job.match_id or '-' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500">{{ job.current_stage or '-' }} · {{ job.progress }}%</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-black uppercase
|
||||
{% if job.status == 'succeeded' %}bg-emerald-100 text-emerald-700
|
||||
{% elif job.status == 'failed' %}bg-red-100 text-red-700
|
||||
{% elif job.status == 'running' %}bg-amber-100 text-amber-700
|
||||
{% else %}bg-slate-100 text-slate-600{% endif %}">{{ job.status }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-mono text-xs text-slate-500">{{ '%.2fs'|format(job.duration_seconds) if job.duration_seconds is not none else '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="px-5 py-12 text-center text-sm text-slate-400">当前筛选没有作业</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
{% if selected_job %}
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-black uppercase tracking-wider text-slate-400">Job Detail</div>
|
||||
<h2 class="mt-1 text-xl font-black text-slate-900 dark:text-white">#{{ selected_job.id }} {{ selected_job.job_type }}</h2>
|
||||
</div>
|
||||
<span id="job-status" class="rounded-full bg-slate-100 px-3 py-1 text-xs font-black uppercase text-slate-600">{{ selected_job.status }}</span>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex justify-between text-xs text-slate-500"><span id="job-stage">{{ selected_job.current_stage or selected_job.status }}</span><span id="job-progress-label">{{ selected_job.progress }}%</span></div>
|
||||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"><div id="job-progress" class="h-full rounded-full bg-yrtv-500 transition-all" style="width:{{ selected_job.progress }}%"></div></div>
|
||||
<p id="job-message" class="mt-3 text-sm text-slate-600 dark:text-slate-300">{{ selected_job.message }}</p>
|
||||
</div>
|
||||
<dl class="mt-5 grid grid-cols-2 gap-3 text-xs">
|
||||
<div class="rounded-lg bg-slate-50 p-3 dark:bg-slate-800"><dt class="text-slate-400">Created</dt><dd class="mt-1 font-mono text-slate-700 dark:text-slate-200">{{ selected_job.created_at }}</dd></div>
|
||||
<div class="rounded-lg bg-slate-50 p-3 dark:bg-slate-800"><dt class="text-slate-400">Duration</dt><dd class="mt-1 font-mono text-slate-700 dark:text-slate-200">{{ '%.3fs'|format(selected_job.duration_seconds) if selected_job.duration_seconds is not none else '-' }}</dd></div>
|
||||
</dl>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<h3 class="text-xs font-black uppercase tracking-wider text-slate-400">Execution Log</h3>
|
||||
<button onclick="copyJobLog()" class="text-xs font-bold text-yrtv-600">复制日志</button>
|
||||
</div>
|
||||
<pre id="job-log" class="mt-2 max-h-[420px] overflow-auto whitespace-pre-wrap rounded-xl bg-slate-950 p-4 text-[11px] leading-5 text-slate-300">{{ selected_job.log_text }}</pre>
|
||||
{% else %}
|
||||
<div class="flex min-h-[420px] flex-col items-center justify-center text-center">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-2xl dark:bg-slate-800">⌘</div>
|
||||
<h2 class="mt-4 font-black text-slate-800 dark:text-white">选择一个作业</h2>
|
||||
<p class="mt-2 max-w-xs text-sm text-slate-400">查看阶段进度、执行信息和完整构建日志。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
{% if selected_job %}
|
||||
<script>
|
||||
const jobId = {{ selected_job.id }};
|
||||
let jobPoll = null;
|
||||
async function refreshJob() {
|
||||
const response = await fetch(`/admin/api/jobs/${jobId}`);
|
||||
if (!response.ok) return;
|
||||
const job = await response.json();
|
||||
document.getElementById('job-status').textContent = job.status;
|
||||
document.getElementById('job-stage').textContent = job.current_stage || job.status;
|
||||
document.getElementById('job-progress-label').textContent = `${job.progress}%`;
|
||||
document.getElementById('job-progress').style.width = `${job.progress}%`;
|
||||
document.getElementById('job-message').textContent = job.message || '';
|
||||
document.getElementById('job-log').textContent = job.log_text || '';
|
||||
if (['succeeded', 'failed'].includes(job.status) && jobPoll) clearInterval(jobPoll);
|
||||
}
|
||||
function copyJobLog() {
|
||||
navigator.clipboard.writeText(document.getElementById('job-log').textContent);
|
||||
}
|
||||
jobPoll = setInterval(refreshJob, 1500);
|
||||
refreshJob();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,38 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Admin Login
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||
<span class="block sm:inline">{{ message }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form class="mt-8 space-y-6" action="{{ url_for('admin.login') }}" method="POST">
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="token" class="sr-only">Admin Token</label>
|
||||
<input id="token" name="token" type="password" required class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md rounded-b-md focus:outline-none focus:ring-yrtv-500 focus:border-yrtv-500 focus:z-10 sm:text-sm" placeholder="Enter Admin Token">
|
||||
</div>
|
||||
</div>
|
||||
{% block title %}Admin Login - YRTV{% endblock %}
|
||||
|
||||
<div>
|
||||
<button type="submit" class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500">
|
||||
Sign in
|
||||
</button>
|
||||
{% block content %}
|
||||
<div class="mx-auto grid min-h-[640px] max-w-5xl overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900 lg:grid-cols-2">
|
||||
<section class="hidden bg-gradient-to-br from-slate-950 via-slate-900 to-yrtv-950 p-10 text-white lg:flex lg:flex-col lg:justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-black uppercase tracking-[0.3em] text-yrtv-300">YRTV Operations</div>
|
||||
<h1 class="mt-5 text-4xl font-black leading-tight">私人战队数据<br>运营控制台</h1>
|
||||
<p class="mt-5 max-w-sm text-sm leading-7 text-slate-300">管理比赛导入、流水线、数据质量、数据库查询、备份和系统状态。</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{% for label in ['Atomic Pipeline', 'Automated Integrity', 'Read-only SQL', 'Rollback Snapshots'] %}
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3 text-xs font-bold text-slate-300">{{ label }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex items-center px-6 py-12 sm:px-12">
|
||||
<div class="w-full">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-yrtv-100 text-2xl font-black text-yrtv-700">Y</div>
|
||||
<h2 class="mt-6 text-3xl font-black text-slate-900 dark:text-white">管理员登录</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">请输入部署环境配置的 Admin Token。</p>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mt-5 space-y-2">
|
||||
{% for category, message in messages %}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form class="mt-8 space-y-5" action="{{ url_for('admin.login') }}" method="POST">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label for="token" class="text-xs font-black uppercase tracking-wider text-slate-500">Admin Token</label>
|
||||
<input id="token" name="token" type="password" required autocomplete="current-password"
|
||||
class="mt-2 block w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-3 text-slate-900 focus:border-yrtv-500 focus:ring-yrtv-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white"
|
||||
placeholder="Enter your admin token">
|
||||
</div>
|
||||
<button type="submit" class="w-full rounded-xl bg-yrtv-600 px-5 py-3 font-black text-white shadow-lg shadow-yrtv-200 hover:bg-yrtv-500">进入控制台</button>
|
||||
</form>
|
||||
<a href="{{ url_for('main.index') }}" class="mt-5 block text-center text-sm font-bold text-slate-400 hover:text-yrtv-600">返回 YRTV 前台</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+113
-45
@@ -1,52 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">SQL Runner</h2>
|
||||
|
||||
<form action="{{ url_for('admin.sql_runner') }}" method="POST" class="mb-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Database</label>
|
||||
<select name="db_name" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 dark:bg-slate-700 dark:text-white">
|
||||
<option value="l2" {% if db_name == 'l2' %}selected{% endif %}>L2 (Facts)</option>
|
||||
<option value="l3" {% if db_name == 'l3' %}selected{% endif %}>L3 (Features)</option>
|
||||
<option value="web" {% if db_name == 'web' %}selected{% endif %}>Web (App Data)</option>
|
||||
</select>
|
||||
{% block admin_title %}查询控制台{% endblock %}
|
||||
{% block page_title %}只读查询控制台{% endblock %}
|
||||
{% block page_subtitle %}探索 L2 事实、L3 集市和 Web 状态,不允许任何写操作{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<aside class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="border-b border-slate-100 px-4 py-4 dark:border-slate-800">
|
||||
<div class="text-xs font-black uppercase tracking-wider text-slate-400">Database Objects</div>
|
||||
<div class="mt-1 font-black text-slate-800 dark:text-white">{{ db_name|upper }}</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Query</label>
|
||||
<textarea name="query" rows="5" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 font-mono text-sm dark:bg-slate-700 dark:text-white" placeholder="SELECT * FROM table LIMIT 10">{{ query }}</textarea>
|
||||
<div class="max-h-[680px] overflow-auto p-2">
|
||||
{% for item in catalog %}
|
||||
<button type="button" onclick="selectTable('{{ item.name }}')" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<span class="truncate font-mono text-xs text-slate-600 dark:text-slate-300">{{ item.name }}</span>
|
||||
<span class="ml-2 text-[9px] font-black uppercase text-slate-300">{{ item.type }}</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<div class="px-3 py-8 text-center text-xs text-slate-400">没有可用对象</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="bg-yrtv-600 text-white py-2 px-4 rounded hover:bg-yrtv-700">Run Query</button>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 border">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
{% for col in result.columns %}
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border-b">{{ col }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for row in result.rows %}
|
||||
<tr>
|
||||
{% for col in result.columns %}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 border-b">{{ row[col] }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 space-y-6">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="font-black text-slate-900 dark:text-white">SQL Editor</h2>
|
||||
<p class="mt-1 text-xs text-slate-400">仅支持单条 SELECT;未指定 LIMIT 时自动限制 50 行。</p>
|
||||
</div>
|
||||
<form method="GET" action="{{ url_for('admin.sql_runner') }}">
|
||||
<select name="db_name" onchange="this.form.submit()" class="rounded-lg border-slate-200 bg-slate-50 text-sm font-bold dark:border-slate-700 dark:bg-slate-800 dark:text-white">
|
||||
<option value="l2" {% if db_name == 'l2' %}selected{% endif %}>L2 Facts</option>
|
||||
<option value="l3" {% if db_name == 'l3' %}selected{% endif %}>L3 Marts</option>
|
||||
<option value="web" {% if db_name == 'web' %}selected{% endif %}>Web App</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{% if db_name == 'l2' %}
|
||||
{% set samples = [
|
||||
('最近比赛', 'SELECT match_id, map_name, start_time, score_team1, score_team2 FROM fact_matches ORDER BY start_time DESC LIMIT 10'),
|
||||
('队员比赛', 'SELECT steam_id_64, match_id, rating, kills, deaths, adr FROM fact_match_players ORDER BY match_id DESC LIMIT 20')
|
||||
] %}
|
||||
{% elif db_name == 'l3' %}
|
||||
{% set samples = [
|
||||
('Roster Features', 'SELECT steam_id_64, total_matches, score_overall, tier_percentile FROM dm_player_features WHERE score_overall > 0 ORDER BY score_overall DESC'),
|
||||
('周期荣誉', 'SELECT award_type, period_key, steam_id_64, matches, avg_rating FROM dm_player_awards ORDER BY period_start DESC LIMIT 20')
|
||||
] %}
|
||||
{% else %}
|
||||
{% set samples = [
|
||||
('最近作业', 'SELECT id, job_type, status, current_stage, duration_seconds, created_at FROM etl_jobs ORDER BY id DESC LIMIT 10'),
|
||||
('Roster Versions', 'SELECT id, name, effective_from, effective_to, is_current FROM team_roster_versions ORDER BY effective_from DESC')
|
||||
] %}
|
||||
{% endif %}
|
||||
{% for label, sample in samples %}
|
||||
<button type="button" onclick='setQuery({{ sample|tojson }})' class="rounded-full bg-slate-100 px-3 py-1.5 text-xs font-bold text-slate-500 hover:bg-yrtv-100 hover:text-yrtv-700 dark:bg-slate-800">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('admin.sql_runner') }}" method="POST" class="mt-4">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="db_name" value="{{ db_name }}">
|
||||
<textarea id="query-editor" name="query" rows="9" spellcheck="false" class="block w-full resize-y rounded-xl border border-slate-200 bg-slate-950 p-4 font-mono text-sm leading-6 text-emerald-300 shadow-inner focus:border-yrtv-500 focus:ring-yrtv-500" placeholder="SELECT * FROM table_name LIMIT 10">{{ query }}</textarea>
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-xs text-slate-400"><span class="h-2 w-2 rounded-full bg-emerald-500"></span> Read-only guard enabled</div>
|
||||
<button type="submit" class="rounded-xl bg-yrtv-600 px-5 py-2.5 text-sm font-black text-white hover:bg-yrtv-500">执行查询</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if error %}
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-700">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<h2 class="font-black text-slate-900 dark:text-white">查询结果</h2>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 font-mono text-slate-500">{{ row_count }} rows</span>
|
||||
<span class="rounded-full bg-emerald-100 px-3 py-1 font-mono text-emerald-700">{{ '%.2fms'|format(duration_ms or 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-h-[620px] overflow-auto">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-left dark:divide-slate-800">
|
||||
<thead class="sticky top-0 bg-slate-50 text-[10px] font-black uppercase tracking-wider text-slate-400 dark:bg-slate-950">
|
||||
<tr>{% for col in result.columns %}<th class="whitespace-nowrap px-4 py-3">{{ col }}</th>{% endfor %}</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 font-mono text-xs dark:divide-slate-800">
|
||||
{% for row in result.rows %}
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
{% for col in result.columns %}
|
||||
<td class="max-w-[320px] px-4 py-3 text-slate-600 dark:text-slate-300" title="{{ row[col] }}">{{ row[col]|string|truncate(160) }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
function setQuery(value) {
|
||||
document.getElementById('query-editor').value = value;
|
||||
}
|
||||
function selectTable(name) {
|
||||
setQuery(`SELECT * FROM "${name}" LIMIT 20`);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block admin_title %}系统信息{% endblock %}
|
||||
{% block page_title %}系统与存储{% endblock %}
|
||||
{% block page_subtitle %}数据库文件、备份快照、运行参数与安全配置{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="space-y-6">
|
||||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{% for database in databases %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-black uppercase tracking-widest text-slate-400">{{ database.name }}</div>
|
||||
<span class="h-2.5 w-2.5 rounded-full {% if database.exists and database.quick_check in ['ok', 'managed by app'] %}bg-emerald-500{% else %}bg-red-500{% endif %}"></span>
|
||||
</div>
|
||||
<div class="mt-3 text-2xl font-black text-slate-900 dark:text-white">{{ database.size_text }}</div>
|
||||
<div class="mt-1 truncate font-mono text-[10px] text-slate-400" title="{{ database.path }}">{{ database.path }}</div>
|
||||
<div class="mt-3 text-xs font-bold {% if database.quick_check in ['ok', 'managed by app'] %}text-emerald-600{% else %}text-red-600{% endif %}">{{ database.quick_check }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<div>
|
||||
<h2 class="font-black text-slate-900 dark:text-white">回滚快照</h2>
|
||||
<p class="text-xs text-slate-400">Pipeline 自动保留最近 3 组</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-mono text-slate-500">{{ backup_summary.sets }} sets</span>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{% for backup in backups %}
|
||||
<div class="px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-black text-slate-800 dark:text-white">{{ backup.name }}</div>
|
||||
<div class="mt-1 text-xs text-slate-400">{{ backup.created_at or 'No manifest timestamp' }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-mono text-sm font-black text-slate-700 dark:text-slate-200">{{ backup.size_text }}</div>
|
||||
<div class="text-[10px] uppercase text-emerald-600">Verified snapshot</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
{% for name, item in backup.databases.items() %}
|
||||
<span class="rounded bg-slate-100 px-2 py-1 text-[10px] font-bold uppercase text-slate-500 dark:bg-slate-800">{{ name }} · {{ item.quick_check }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-12 text-center text-sm text-slate-400">暂无备份快照</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<h2 class="font-black text-slate-900 dark:text-white">运行参数</h2>
|
||||
<dl class="mt-4 space-y-3 text-sm">
|
||||
{% for label, value in [
|
||||
('Web schema', 'v' + config.web_schema_version|string),
|
||||
('SQLite timeout', config.sqlite_timeout|string + 's'),
|
||||
('Slow query', config.slow_query_threshold|string + 's'),
|
||||
('Max upload', '%.0f MB'|format(config.max_upload_mb))
|
||||
] %}
|
||||
<div class="flex items-center justify-between"><dt class="text-slate-500">{{ label }}</dt><dd class="font-mono font-black text-slate-800 dark:text-slate-200">{{ value }}</dd></div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||
<h2 class="font-black text-slate-900 dark:text-white">安全配置</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
{% for label, configured in [
|
||||
('SECRET_KEY', config.secret_key_configured),
|
||||
('ADMIN_TOKEN', config.admin_token_configured)
|
||||
] %}
|
||||
<div class="flex items-center justify-between rounded-lg bg-slate-50 p-3 dark:bg-slate-800">
|
||||
<span class="font-mono text-xs text-slate-600 dark:text-slate-300">{{ label }}</span>
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-black uppercase {% if configured %}bg-emerald-100 text-emerald-700{% else %}bg-amber-100 text-amber-700{% endif %}">{{ 'configured' if configured else 'dev default' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="mt-4 text-xs leading-5 text-slate-400">本页只展示配置状态,不回显任何密钥或令牌。</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user