feat: add concurrency safety, caption detection, admin enhancements, and performance improvements
This commit is contained in:
@@ -69,7 +69,8 @@
|
||||
<span class="info-label">活跃任务</span>
|
||||
<span class="info-value">
|
||||
{% for lock in stats.active_locks %}
|
||||
<span class="task-badge task-{{ lock.task }}">{{ lock.task }}</span>
|
||||
<span class="task-badge task-{{ lock.task }}" title="{{ lock.status }} · #{{ lock.id }}">{{ lock.task }}</span>
|
||||
<button class="admin-action-btn admin-action-btn-sm admin-action-btn-danger" title="强制释放锁 #{{ lock.id }}" onclick="releaseLock({{ lock.id }})">🔓</button>
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
@@ -118,6 +119,15 @@
|
||||
<div class="info-row"><span class="info-label">数据库</span><span class="info-value">{{ stats.db_size }}</span></div>
|
||||
<div class="info-row"><span class="info-label">论文文件</span><span class="info-value">{{ stats.papers_size }}</span></div>
|
||||
<div class="info-row"><span class="info-label">临时文件</span><span class="info-value">{{ stats.tmp_size }}</span></div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">搜索索引</span>
|
||||
<span class="info-value">
|
||||
<button class="admin-action-btn admin-action-btn-sm" onclick="rebuildIndexes('fts')">🔤 重建全文</button>
|
||||
{% if stats.config_overview.chroma_enabled %}
|
||||
<button class="admin-action-btn admin-action-btn-sm" onclick="rebuildIndexes('chroma')">🧠 重建语义</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-dist">
|
||||
<h3 class="section-subtitle">总结状态分布</h3>
|
||||
@@ -136,6 +146,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-info-card">
|
||||
<h2 class="admin-info-title">⚙️ 运行配置</h2>
|
||||
<div class="admin-info-body">
|
||||
<div class="info-row"><span class="info-label">总结后端</span><span class="info-value">{{ stats.config_overview.summary_backend }} · {{ stats.config_overview.summary_pdf_mode }} 模式</span></div>
|
||||
<div class="info-row"><span class="info-label">并发/超时</span><span class="info-value">{{ stats.config_overview.summary_concurrency }} 并发 · {{ stats.config_overview.summary_timeout_seconds }}s · 重试 {{ stats.config_overview.summary_max_retries }}</span></div>
|
||||
<div class="info-row"><span class="info-label">调度</span><span class="info-value">{{ '启用' if stats.config_overview.scheduler_enabled else '未启用' }} · {{ stats.config_overview.schedule_time }} · {{ stats.config_overview.app_workers }} worker</span></div>
|
||||
<div class="info-row"><span class="info-label">语义搜索</span><span class="info-value">{{ '启用' if stats.config_overview.chroma_enabled else '未启用' }} · {{ stats.config_overview.embed_model }}</span></div>
|
||||
<div class="info-row"><span class="info-label">抓取</span><span class="info-value">TOP {{ stats.config_overview.top_n }} · 投票刷新 {{ stats.config_overview.upvote_refresh_days }} 天</span></div>
|
||||
<div class="info-row"><span class="info-label">布局模型</span><span class="info-value">{{ stats.config_overview.layout_model }}</span></div>
|
||||
<div class="info-row"><span class="info-label">数据库</span><span class="info-value">{{ stats.config_overview.database_url }}</span></div>
|
||||
<div class="info-row"><span class="info-label">嵌入密钥</span><span class="info-value">{{ '已配置' if stats.config_overview.api_key_configured else '未配置' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
@@ -193,5 +216,17 @@
|
||||
.then(data => { if (data) showToast(data.error ? "❌ " + data.error.substring(0,200) : `✅ 已刷新 ${data.updated || 0} 篇论文投票`); })
|
||||
.catch(err => showToast("❌ 请求失败"));
|
||||
}
|
||||
function releaseLock(lockId) {
|
||||
fetch("/admin/locks/"+lockId+"/release", { method: "POST", headers: { "Content-Type": "application/json" } })
|
||||
.then(r => { if (r.status===303||r.status===401) { window.location.href="/admin/login"; return; } return r.json(); })
|
||||
.then(data => { if (data) showToast(data.error ? "❌ " + data.error.substring(0,200) : "✅ 已释放锁,刷新中…", {callback:function(){location.reload();}}); })
|
||||
.catch(err => showToast("❌ 请求失败"));
|
||||
}
|
||||
function rebuildIndexes(target) {
|
||||
fetch("/admin/rebuild-indexes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({target: target}) })
|
||||
.then(r => { if (r.status===303||r.status===401) { window.location.href="/admin/login"; return; } return r.json(); })
|
||||
.then(data => { if (data) showToast(data.error ? "❌ " + data.error.substring(0,200) : "✅ 重建任务已创建,可在任务页查看"); })
|
||||
.catch(err => showToast("❌ 请求失败"));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}任务监控 — HF Daily Papers{% endblock %}
|
||||
|
||||
{% set type_label = {"crawl_daily":"抓取","pipeline_daily":"流水线","summarize_batch":"批量总结","summarize_one":"单篇总结","refresh_upvotes":"刷新投票","delete_range":"删除","cleanup_tmp":"清理","reindex_fts":"重建全文","reindex_chroma":"重建语义","recrawl_one":"重抓","recrawl_batch":"批量重抓"} %}
|
||||
{% set type_badge = {"crawl_daily":"task-crawl","pipeline_daily":"task-crawl","recrawl_one":"task-crawl","recrawl_batch":"task-crawl","refresh_upvotes":"task-crawl","summarize_batch":"task-summarize","summarize_one":"task-summarize","cleanup_tmp":"task-cleanup","delete_range":"task-delete","reindex_fts":"task-reindex","reindex_chroma":"task-reindex"} %}
|
||||
{% set status_label = {"queued":"排队","running":"运行中","success":"成功","failed":"失败","stale":"已过期","cancelled":"已取消"} %}
|
||||
{% set status_badge = {"queued":"status-queued","running":"status-running","success":"status-success","failed":"status-failed","stale":"status-stale","cancelled":"status-stale"} %}
|
||||
|
||||
{% macro fmt_duration(s) -%}
|
||||
{%- if s is none %}-
|
||||
{%- elif s < 60 %}{{ "%.0f"|format(s) }}s
|
||||
{%- elif s < 3600 %}{{ (s // 60)|int }}m {{ (s % 60)|round|int }}s
|
||||
{%- else %}{{ (s // 3600)|int }}h {{ ((s % 3600) // 60)|int }}m
|
||||
{%- endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-page">
|
||||
{% set active = "jobs" %}{% include "partials/admin_subnav.html" %}
|
||||
|
||||
<h1 class="page-heading">🧰 任务监控</h1>
|
||||
|
||||
{% set _total = (status_counts.values() | sum) if status_counts else 0 %}
|
||||
<div class="summary-stats-row">
|
||||
<span class="summary-stat">总计 <strong>{{ _total }}</strong></span>
|
||||
<span class="summary-stat summary-stat-pending">排队 <strong>{{ status_counts.get('queued', 0) }}</strong></span>
|
||||
<span class="summary-stat">运行中 <strong>{{ status_counts.get('running', 0) }}</strong></span>
|
||||
<span class="summary-stat summary-stat-done">成功 <strong>{{ status_counts.get('success', 0) }}</strong></span>
|
||||
<span class="summary-stat summary-stat-failed">失败 <strong>{{ status_counts.get('failed', 0) + status_counts.get('stale', 0) }}</strong></span>
|
||||
</div>
|
||||
|
||||
{% set statuses = [("all","全部"),("queued","排队"),("running","运行中"),("success","成功"),("failed","失败"),("stale","已过期")] %}
|
||||
<div class="summary-filters">
|
||||
<span class="summary-filter-label">状态:</span>
|
||||
{% for key, label in statuses %}
|
||||
<a class="filter-chip {{ 'active' if current_status == key else '' }}"
|
||||
href="?status={{ key }}{% if current_type != 'all' %}&type={{ current_type }}{% endif %}">{{ label }}
|
||||
({% if key == 'all' %}{{ _total }}{% else %}{{ status_counts.get(key, 0) }}{% endif %})</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% set types = [("crawl_daily","抓取"),("pipeline_daily","流水线"),("summarize_batch","批量总结"),("summarize_one","单篇总结"),("refresh_upvotes","刷新投票"),("recrawl_one","重抓"),("recrawl_batch","批量重抓"),("delete_range","删除"),("cleanup_tmp","清理"),("reindex_fts","重建全文"),("reindex_chroma","重建语义")] %}
|
||||
<form method="get" class="summary-filters">
|
||||
<span class="summary-filter-label">类型:</span>
|
||||
<input type="hidden" name="status" value="{{ current_status }}" />
|
||||
<select name="type" class="paper-filter-input" onchange="this.form.submit()">
|
||||
<option value="all" {{ 'selected' if current_type == 'all' }}>全部类型</option>
|
||||
{% for key, label in types %}
|
||||
<option value="{{ key }}" {{ 'selected' if current_type == key }}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
|
||||
{% if jobs %}
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>类型</th><th>状态</th><th>触发者</th><th>创建时间</th><th>耗时</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>{{ job.id }}</td>
|
||||
<td><span class="task-badge {{ type_badge.get(job.type, 'task-crawl') }}">{{ type_label.get(job.type, job.type) }}</span></td>
|
||||
<td><span class="status-badge {{ status_badge.get(job.status, 'status-running') }}">{{ status_label.get(job.status, job.status) }}</span></td>
|
||||
<td>{{ job.owner or '-' }}</td>
|
||||
<td class="time-cell">{{ job.created_at.strftime('%Y-%m-%d %H:%M:%S') if job.created_at else '-' }}</td>
|
||||
<td class="time-cell">{{ fmt_duration(job.duration_seconds) }}</td>
|
||||
<td class="action-cell"><button class="action-btn-sm" title="详情" onclick="showJobDetail({{ job.id }})">📋</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% set total_pages = ((total + per_page - 1) // per_page) if total else 1 %}
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}<a class="page-btn" href="{{ pagination_url(page - 1) }}">← 上一页</a>{% endif %}
|
||||
<span class="page-info">第 {{ page }} / {{ total_pages }} 页(共 {{ total }} 个)</span>
|
||||
{% if page < total_pages %}<a class="page-btn" href="{{ pagination_url(page + 1) }}">下一页 →</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>暂无任务记录</p>
|
||||
<p class="hint">触发抓取、总结等操作后,任务会出现在这里。可在「详情」中查看阶段事件。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 任务详情 modal -->
|
||||
<div class="confirm-overlay" id="job-detail-overlay" style="display:none;">
|
||||
<div class="confirm-dialog" style="max-width:660px;max-height:85vh;overflow:auto;">
|
||||
<h3 class="admin-info-title" id="job-detail-title">任务详情</h3>
|
||||
<div id="job-detail-body"><p class="hint">加载中...</p></div>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-btn confirm-btn-cancel" onclick="closeJobDetail()">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const TYPE_LABEL = {"crawl_daily":"抓取","pipeline_daily":"流水线","summarize_batch":"批量总结","summarize_one":"单篇总结","refresh_upvotes":"刷新投票","delete_range":"删除","cleanup_tmp":"清理","reindex_fts":"重建全文","reindex_chroma":"重建语义","recrawl_one":"重抓","recrawl_batch":"批量重抓"};
|
||||
const STATUS_LABEL = {"queued":"排队","running":"运行中","success":"成功","failed":"失败","stale":"已过期","cancelled":"已取消"};
|
||||
|
||||
function fmtTime(s){ return s ? s.replace('T',' ').slice(0,19) : '-'; }
|
||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
function eventBadge(s){ return {'success':'status-success','failed':'status-failed','started':'status-running','info':'status-queued'}[s] || 'status-queued'; }
|
||||
function jobStatusBadge(s){ return {'success':'success','failed':'failed','running':'running','stale':'stale','cancelled':'stale','queued':'queued'}[s] || 'running'; }
|
||||
function infoRow(label, val){ return '<div class="info-row"><span class="info-label">'+label+'</span><span class="info-value">'+val+'</span></div>'; }
|
||||
|
||||
function showJobDetail(id){
|
||||
document.getElementById('job-detail-overlay').style.display='flex';
|
||||
document.getElementById('job-detail-body').innerHTML='<p class="hint">加载中...</p>';
|
||||
fetch('/admin/jobs/'+id)
|
||||
.then(r=>{if(r.status===303||r.status===401){window.location.href='/admin/login';return;}return r.json();})
|
||||
.then(d=>{ if(d) renderJobDetail(d); })
|
||||
.catch(()=>{document.getElementById('job-detail-body').innerHTML='<p class="hint">加载失败</p>';});
|
||||
}
|
||||
function renderJobDetail(d){
|
||||
let h='<div class="admin-info-body">';
|
||||
h+=infoRow('ID', d.id);
|
||||
h+=infoRow('类型', esc(TYPE_LABEL[d.type]||d.type));
|
||||
h+=infoRow('状态', '<span class="status-badge status-'+jobStatusBadge(d.status)+'">'+esc(STATUS_LABEL[d.status]||d.status)+'</span>');
|
||||
h+=infoRow('触发者', esc(d.owner||'-'));
|
||||
h+=infoRow('创建', fmtTime(d.created_at));
|
||||
h+=infoRow('开始', fmtTime(d.started_at));
|
||||
h+=infoRow('完成', fmtTime(d.completed_at));
|
||||
if(d.payload && Object.keys(d.payload).length) h+=infoRow('参数', '<code style="word-break:break-all;">'+esc(JSON.stringify(d.payload))+'</code>');
|
||||
if(d.result) h+=infoRow('结果', '<code style="word-break:break-all;">'+esc(JSON.stringify(d.result))+'</code>');
|
||||
if(d.error) h+=infoRow('错误', '<span class="error-cell" style="max-width:480px;">'+esc(d.error)+'</span>');
|
||||
h+='</div>';
|
||||
if(d.events && d.events.length){
|
||||
h+='<h3 class="section-subtitle" style="margin-top:18px;">事件时间线</h3>';
|
||||
h+='<div class="admin-table-wrap" style="max-height:220px;overflow:auto;"><table class="admin-table admin-table-compact"><thead><tr><th>阶段</th><th>状态</th><th>时间</th><th>消息</th></tr></thead><tbody>';
|
||||
d.events.forEach(e=>{
|
||||
h+='<tr><td>'+esc(e.stage)+'</td><td><span class="status-badge '+eventBadge(e.status)+'">'+esc(e.status)+'</span></td><td class="time-cell">'+fmtTime(e.created_at)+'</td><td class="error-cell" style="max-width:240px;">'+esc(e.message||'')+'</td></tr>';
|
||||
});
|
||||
h+='</tbody></table></div>';
|
||||
}
|
||||
document.getElementById('job-detail-body').innerHTML=h;
|
||||
}
|
||||
function closeJobDetail(){ document.getElementById('job-detail-overlay').style.display='none'; }
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeJobDetail();});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -109,6 +109,22 @@
|
||||
<span class="summary-stat summary-stat-failed">失败 <strong>{{ summary_failed or 0 }}</strong></span>
|
||||
<span class="summary-stat summary-stat-done">已完成 <strong>{{ summary_done or 0 }}</strong></span>
|
||||
</div>
|
||||
{% if failure_breakdown %}
|
||||
<div class="summary-dist" style="margin-top:12px;">
|
||||
<h3 class="section-subtitle">失败原因分布({{ summary_failed or 0 }} 篇)</h3>
|
||||
<div class="summary-dist-bars">
|
||||
{% set fb_total = (failure_breakdown | map(attribute='count') | sum) or 1 %}
|
||||
{% set error_labels = {"pdf_download_failed":"PDF下载失败","timeout":"超时","process_error":"进程错误","json_not_found":"JSON缺失","json_invalid":"JSON无效","field_missing":"字段缺失","schema_error":"结构错误","unknown":"未分类"} %}
|
||||
{% for item in failure_breakdown %}
|
||||
<div class="dist-row">
|
||||
<span class="dist-label">{{ error_labels.get(item.error_type, item.error_type) }}</span>
|
||||
<div class="dist-bar-wrap"><div class="dist-bar dist-bar-failed" style="width:{{ (item.count / fb_total * 100)|round(1) }}%"></div></div>
|
||||
<span class="dist-count">{{ item.count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="summary-list"
|
||||
hx-get="/admin/summary-status"
|
||||
hx-trigger="load"
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<option value="title_asc" {% if current_sort == 'title_asc' %}selected{% endif %}>标题 A→Z</option>
|
||||
</select>
|
||||
<button type="submit" class="paper-search-btn">搜索</button>
|
||||
<a class="admin-action-btn admin-action-btn-sm" href="/admin/papers/export.csv{% if request.query_params %}?{{ request.query_params }}{% endif %}">⬇ 导出 CSV</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -37,6 +38,7 @@
|
||||
<span class="paper-batch-label">批量操作</span>
|
||||
<span class="paper-selected-count" id="selected-count">已选 0 篇</span>
|
||||
<button class="admin-action-btn admin-action-btn-sm" onclick="batchAction('summarize')" id="batch-summarize-btn" disabled>📝 批量总结</button>
|
||||
<button class="admin-action-btn admin-action-btn-sm" onclick="batchAction('recrawl')" id="batch-recrawl-btn" disabled>🔄 批量重抓</button>
|
||||
<button class="admin-action-btn admin-action-btn-sm admin-action-btn-danger" onclick="batchAction('delete')" id="batch-delete-btn" disabled>🗑 批量删除</button>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +74,7 @@
|
||||
</td>
|
||||
<td class="action-cell">
|
||||
<button class="action-btn-sm" title="重新总结" onclick="retryOne('{{ paper.arxiv_id }}', this)">↻</button>
|
||||
<button class="action-btn-sm" title="重新抓取元数据" onclick="recrawlOne('{{ paper.arxiv_id }}', this)">🔄</button>
|
||||
<button class="action-btn-sm action-btn-danger" title="删除" onclick="confirmDeleteSingle('{{ paper.arxiv_id }}', '{{ (paper.title_zh or paper.title_en)[:40] | replace("'", "\\'") }}')">🗑</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -124,6 +127,7 @@
|
||||
const n=document.querySelectorAll('.paper-check:checked').length;
|
||||
document.getElementById('selected-count').textContent='已选 '+n+' 篇';
|
||||
document.getElementById('batch-summarize-btn').disabled=n===0;
|
||||
document.getElementById('batch-recrawl-btn').disabled=n===0;
|
||||
document.getElementById('batch-delete-btn').disabled=n===0;
|
||||
}
|
||||
function retryOne(arxivId,btn) {
|
||||
@@ -134,6 +138,14 @@
|
||||
.catch(()=>showToast('❌ 请求失败'))
|
||||
.finally(()=>{btn.disabled=false;btn.textContent='↻';});
|
||||
}
|
||||
function recrawlOne(arxivId,btn) {
|
||||
btn.disabled=true;btn.textContent='...';
|
||||
fetch('/admin/paper-recrawl/'+arxivId,{method:'POST',headers:{'Content-Type':'application/json'}})
|
||||
.then(r=>r.json())
|
||||
.then(data=>showToast(data.error?'❌ '+data.error.substring(0,100):'✅ 重抓任务已创建,可在任务页查看'))
|
||||
.catch(()=>showToast('❌ 请求失败'))
|
||||
.finally(()=>{btn.disabled=false;btn.textContent='🔄';});
|
||||
}
|
||||
function confirmDeleteSingle(arxivId,title) {
|
||||
document.getElementById('confirm-msg').textContent='确定删除论文「'+title+'」?此操作不可恢复。';
|
||||
_confirmAction='delete-single'; _confirmTarget=arxivId;
|
||||
@@ -151,6 +163,11 @@
|
||||
.then(r=>r.json())
|
||||
.then(data=>showToast(data.error?'❌ '+data.error.substring(0,100):'✅ 已提交批量总结'))
|
||||
.catch(()=>showToast('❌ 请求失败'));
|
||||
} else if(action==='recrawl'){
|
||||
fetch('/admin/papers-batch-action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'recrawl',arxiv_ids:ids})})
|
||||
.then(r=>r.json())
|
||||
.then(data=>showToast(data.error?'❌ '+data.error.substring(0,100):'✅ 已提交批量重抓,可在任务页查看'))
|
||||
.catch(()=>showToast('❌ 请求失败'));
|
||||
}
|
||||
}
|
||||
function doConfirmAction() {
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
<a href="/reading-list">阅读列表</a>
|
||||
{% if is_admin %}
|
||||
<a href="/admin/">管理</a>
|
||||
<a href="/admin/logout" onclick="event.preventDefault();this.closest('form').submit()">退出</a>
|
||||
<form action="/admin/logout" method="post" style="display:none"></form>
|
||||
{% else %}
|
||||
<a href="/admin/login">管理</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{# Admin subnav — 管理后台三个页面共享。active 参数: "dashboard" / "papers" / "logs" #}
|
||||
{# Admin subnav — 管理后台共享。active 参数: "dashboard" / "papers" / "jobs" / "logs" #}
|
||||
<nav class="admin-subnav">
|
||||
<a href="/admin/" class="admin-subnav-link {{ 'active' if active == 'dashboard' else '' }}">仪表盘</a>
|
||||
<a href="/admin/papers" class="admin-subnav-link {{ 'active' if active == 'papers' else '' }}">论文管理</a>
|
||||
<a href="/admin/jobs" class="admin-subnav-link {{ 'active' if active == 'jobs' else '' }}">任务</a>
|
||||
<a href="/admin/logs" class="admin-subnav-link {{ 'active' if active == 'logs' else '' }}">日志</a>
|
||||
<span class="admin-subnav-spacer"></span>
|
||||
<form action="/admin/logout" method="post" class="admin-subnav-form">
|
||||
|
||||
Reference in New Issue
Block a user