WebPanel: Redesign Email Audit section, add stats widgets and dynamic AJAX counters

This commit is contained in:
Admin 2026-06-27 10:35:22 +04:00
parent 35ffe3442c
commit ed565c54c3
4 changed files with 311 additions and 117 deletions

View file

@ -14,6 +14,9 @@ services:
- ./data:/app/data
- ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
- /home/matrixhasyou/domovoy_drive_sync.py:/home/matrixhasyou/domovoy_drive_sync.py:ro
- /home/matrixhasyou/.muttrc:/home/matrixhasyou/.muttrc:ro
logging:
driver: "json-file"
options:
@ -34,6 +37,9 @@ services:
- ./data:/app/data
- ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
- /home/matrixhasyou/domovoy_drive_sync.py:/home/matrixhasyou/domovoy_drive_sync.py:ro
- /home/matrixhasyou/.muttrc:/home/matrixhasyou/.muttrc:ro
logging:
driver: "json-file"
options:

View file

@ -15,4 +15,13 @@ pydub
psycopg2-binary>=2.9.9
asyncpg>=0.29.0
google-generativeai
imap-tools
weasyprint
fpdf
pymupdf
google-api-python-client
google-auth
google-auth-httplib2
google-auth-oauthlib

View file

@ -691,17 +691,45 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
except:
last_sync_time = datetime.fromtimestamp(sync_time_file.stat().st_mtime, tz=timezone.utc)
# Global stats calculation
total_inbound = sum(d.inbound_count for d in audit_data)
total_outbound = sum(d.outbound_count for d in audit_data)
total_emails = total_inbound + total_outbound
total_attachments = 0
export_log_path = Path("/home/matrixhasyou/mutt/export.log")
if export_log_path.exists():
try:
content = export_log_path.read_text(errors='ignore')
matches = re.findall(r"Has (\d+) attachment", content)
total_attachments = sum(int(m) for m in matches)
except Exception as e:
pass
pdf_size_mb = 0.0
if master_pdf_path.exists():
pdf_size_mb = round(master_pdf_path.stat().st_size / (1024 * 1024), 2)
last_email_date = None
if audit_data:
dates = [d.last_email_date for d in audit_data if d.last_email_date]
if dates:
last_email_date = max(dates)
return templates.TemplateResponse(request=request, name="emails.html", context={
"username": username, "audit_data": audit_data,
"last_log": last_log, "latest_emails": latest_emails,
"has_master_pdf": master_pdf_path.exists(), "last_update": last_update,
"last_sync_time": last_sync_time, "fmt_local": fmt_local
"last_sync_time": last_sync_time, "fmt_local": fmt_local,
"total_inbound": total_inbound, "total_outbound": total_outbound,
"total_emails": total_emails, "total_attachments": total_attachments,
"pdf_size_mb": pdf_size_mb, "last_email_date": last_email_date
})
@app.post("/api/emails/run_full_sync")
async def api_emails_run_full_sync(username: str = Depends(get_current_admin)):
master_script = "/home/matrixhasyou/mutt/master_ai_sync.py"
python_path = "/usr/bin/python3"
python_path = sys.executable
if not os.path.exists(master_script):
return JSONResponse(status_code=404, content={"message": "Master sync script not found"})
@ -716,7 +744,7 @@ async def api_emails_run_full_sync(username: str = Depends(get_current_admin)):
@app.post("/api/emails/sync_ai")
async def api_emails_sync_ai(username: str = Depends(get_current_admin)):
sync_script = "/home/matrixhasyou/domovoy_drive_sync.py"
python_path = "/home/matrixhasyou/domovoy_bot/venv-google/bin/python"
python_path = sys.executable
master_pdf = "/home/matrixhasyou/mutt/TOTAL_ARCHIVE_2025-2026.pdf"
if not os.path.exists(sync_script):
@ -733,18 +761,68 @@ async def api_emails_sync_ai(username: str = Depends(get_current_admin)):
async def api_emails_pipeline_status(username: str = Depends(get_current_admin)):
import time
status_file = Path("/home/matrixhasyou/mutt/pipeline_status.json")
if not status_file.exists():
return JSONResponse(content={"status": "idle", "step": "Неактивен", "updated_at": "-", "new_emails": 0, "total_emails": 0, "pdf_size_mb": 0.0})
master_pdf_path = Path("/home/matrixhasyou/mutt/ALL_EMAILS_CONSOLIDATED.pdf")
# Calculate fallback values
pdf_size_mb = 0.0
if master_pdf_path.exists():
pdf_size_mb = round(master_pdf_path.stat().st_size / (1024 * 1024), 2)
total_emails = 0
total_inbound = 0
total_outbound = 0
async with AsyncSessionLocal() as session:
audit_data = (await session.execute(select(EmailAudit))).scalars().all()
total_inbound = sum(d.inbound_count for d in audit_data)
total_outbound = sum(d.outbound_count for d in audit_data)
total_emails = total_inbound + total_outbound
total_attachments = 0
export_log_path = Path("/home/matrixhasyou/mutt/export.log")
if export_log_path.exists():
try:
content = export_log_path.read_text(errors='ignore')
matches = re.findall(r"Has (\d+) attachment", content)
total_attachments = sum(int(m) for m in matches)
except Exception as e:
pass
sync_time_file = Path("/home/matrixhasyou/mutt/last_ai_sync.txt")
last_sync_time = "-"
if sync_time_file.exists():
try:
last_sync_time = sync_time_file.read_text().strip()
except:
pass
data = {
"status": "idle",
"step": "Неактивен",
"updated_at": "-",
"new_emails": 0,
"total_emails": total_emails,
"total_inbound": total_inbound,
"total_outbound": total_outbound,
"total_attachments": total_attachments,
"pdf_size_mb": pdf_size_mb,
"last_sync_time": last_sync_time
}
if status_file.exists():
try:
with open(status_file) as f:
data = json.load(f)
file_data = json.load(f)
data.update(file_data)
mtime = status_file.stat().st_mtime
if data.get("status") == "running" and (time.time() - mtime > 600):
data["status"] = "stuck"
data["step"] = "Процесс завис или был остановлен"
return JSONResponse(content=data)
except Exception as e:
return JSONResponse(content={"status": "error", "message": str(e)})
data["status"] = "error"
data["message"] = str(e)
return JSONResponse(content=data)
@app.get("/infra", response_class=HTMLResponse)
async def get_infra_page(request: Request, username: str = Depends(get_current_admin)):

View file

@ -4,6 +4,7 @@
{% block nav_emails %}active{% endblock %}
{% block content %}
<!-- ЗАГОЛОВОК -->
<div class="row mb-4">
<div class="col-md-8">
<h2 style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">
@ -15,20 +16,87 @@
</div>
</div>
<!-- МИНИ-КАРТОЧКИ АНАЛИТИКИ (ВИДЖЕТЫ) -->
<div class="row g-3 mb-4">
<!-- КАРТОЧКА 1: ВСЕГО ПИСЕМ -->
<div class="col-md-3">
<div class="card bg-dark border-success h-100 shadow">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h6 class="text-success text-uppercase small font-monospace mb-0">>_ ВСЕГО_ПИСЕМ</h6>
<i class="bi bi-envelope-open text-success fs-4"></i>
</div>
<h2 class="display-6 font-monospace my-2 text-white" id="statTotalEmails">{{ total_emails }}</h2>
<div class="small text-muted">
Вх: <span id="statInboundEmails" class="text-success fw-bold">{{ total_inbound }}</span> /
Исх: <span id="statOutboundEmails" class="text-info fw-bold">{{ total_outbound }}</span>
</div>
</div>
</div>
</div>
<!-- КАРТОЧКА 2: ОБЪЕМ АРХИВА -->
<div class="col-md-3">
<div class="card bg-dark border-info h-100 shadow">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h6 class="text-info text-uppercase small font-monospace mb-0">>_ ОБЪЕМ_АРХИВА</h6>
<i class="bi bi-file-earmark-pdf text-info fs-4"></i>
</div>
<h2 class="display-6 font-monospace my-2 text-white" id="statPdfSize">{{ pdf_size_mb }} MB</h2>
<div class="small text-muted text-truncate" id="statLastUpdate">
Создан: {{ fmt_local(last_update) if last_update else '-' }}
</div>
</div>
</div>
</div>
<!-- КАРТОЧКА 3: ВСЕГО ВЛОЖЕНИЙ -->
<div class="col-md-3">
<div class="card bg-dark border-warning h-100 shadow">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h6 class="text-warning text-uppercase small font-monospace mb-0">>_ ВШИТЫХ_ВЛОЖЕНИЙ</h6>
<i class="bi bi-paperclip text-warning fs-4"></i>
</div>
<h2 class="display-6 font-monospace my-2 text-white" id="statAttachments">{{ total_attachments }}</h2>
<div class="small text-muted">Интегрировано в листы PDF</div>
</div>
</div>
</div>
<!-- КАРТОЧКА 4: ПОСЛЕДНЯЯ СИНХРОНИЗАЦИЯ -->
<div class="col-md-3">
<div class="card bg-dark border-primary h-100 shadow">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h6 class="text-primary text-uppercase small font-monospace mb-0">>_ DRIVE_SYNC</h6>
<i class="bi bi-google-play text-primary fs-4"></i>
</div>
<h2 class="display-6 font-monospace my-2 text-white" id="statLastSync">
{% if last_sync_time %}{{ last_sync_time.strftime('%d.%m.%Y') }}{% else %}-{% endif %}
</h2>
<div class="small text-muted text-truncate" id="statLastSyncTime">
Время: {{ fmt_local(last_sync_time) if last_sync_time else 'Неизвестно' }}
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- СТАТУС АРХИВА -->
<!-- ЛЕВАЯ КОЛОНКА: УПРАВЛЕНИЕ И СТАТУС -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-header">АРХИВ_PDF (NOTEBOOK_LM)</div>
<div class="card-body text-center d-flex flex-column justify-content-center">
<!-- КАРТОЧКА УПРАВЛЕНИЯ -->
<div class="card mb-4 shadow">
<div class="card-header bg-dark border-bottom border-secondary text-success font-monospace">
[CONTROL] УПРАВЛЕНИЕ_АРХИВОМ
</div>
<div class="card-body text-center py-4">
{% if has_master_pdf %}
<i class="bi bi-file-earmark-pdf-fill display-1 text-danger mb-3"></i>
<h5>ALL_EMAILS_CONSOLIDATED.pdf</h5>
<p class="small opacity-75"><b>Файл создан:</b> {{ fmt_local(last_update) }}</p>
<p class="small opacity-75"><b>G-Drive Sync:</b> {{ fmt_local(last_sync_time) if last_sync_time else 'НЕИЗВЕСТНО' }}</p>
<i class="bi bi-file-earmark-pdf-fill display-2 text-danger mb-3"></i>
<h6 class="text-white">ALL_EMAILS_CONSOLIDATED.pdf</h6>
<p class="small opacity-50 mb-4">Архив готов к использованию в NotebookLM</p>
<button id="fullSyncBtn" class="btn btn-warning w-100 mb-2">
<i class="bi bi- lightning-charge-fill"></i> ТУРБО СИНХРОНИЗАЦИЯ
<i class="bi bi-lightning-charge-fill"></i> ТУРБО СИНХРОНИЗАЦИЯ
</button>
<div class="dropdown w-100">
@ -41,39 +109,82 @@
</ul>
</div>
{% else %}
<i class="bi bi-file-earmark-x display-1 opacity-20 mb-3"></i>
<p>Архив еще не сформирован</p>
<button class="btn btn-outline-secondary w-100" disabled>НЕДОСТУПНО</button>
<i class="bi bi-file-earmark-x display-2 opacity-20 mb-3"></i>
<p class="text-warning">Архив еще не сформирован</p>
<button id="fullSyncBtn" class="btn btn-warning w-100 mb-2">
<i class="bi bi-lightning-charge-fill"></i> ЗАПУСТИТЬ ПЕРВЫЙ СБОР
</button>
{% endif %}
</div>
</div>
<!-- СТАТУС ПАЙПЛАЙНА (ПОСТОЯННЫЙ) -->
<div class="card border-primary mb-4 shadow" id="pipelineStatusCard">
<div class="card-header bg-dark border-bottom border-primary d-flex justify-content-between align-items-center text-primary font-monospace">
<span>[PIPELINE] МОНИТОРИНГ_ПРОЦЕССА</span>
<span class="spinner-grow spinner-grow-sm text-primary" id="pipelineSpinner" style="display: none;" role="status"></span>
</div>
<div class="card-body">
<div class="p-3 bg-black border border-secondary font-monospace small">
[STEP] Шаг: <span id="pipelineStep" class="text-info fw-bold">Ожидание</span> <br>
[TIME] Обновлено: <span id="pipelineTime">-</span> <br>
<div class="progress my-3" style="height: 12px; background-color: #111;">
<div id="pipelineProgress" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" role="progressbar" style="width: 0%"></div>
</div>
[DATA] Новых писем: <span id="pipelineNewEmails" class="text-success fw-bold">0</span> <br>
[DATA] Всего в архиве: <span id="pipelineTotalEmails" class="fw-bold">0</span> <br>
[DATA] Размер файла: <span id="pipelineSize" class="fw-bold">0.00 MB</span>
</div>
</div>
</div>
<!-- ПОСЛЕДНЯЯ АКТИВНОСТЬ -->
<!-- ЛОГ ПОСЛЕДНЕГО ЗАПУСКА -->
<div class="card shadow">
<div class="card-header bg-dark border-bottom border-secondary text-warning font-monospace">
[LOG] ЛОГ_ПОСЛЕДНЕГО_ЗАПУСКА
</div>
<div class="card-body">
<div class="p-3 bg-black border border-warning font-monospace small">
{% if last_log %}
[TIME] Начат: {{ fmt_local(last_log.start_time) }} <br>
[TIME] Завершен: {{ fmt_local(last_log.end_time) if last_log.end_time else 'В ПРОЦЕССЕ...' }} <br>
[STATUS] Статус: <span class="{% if last_log.status == 'success' %}text-success{% elif last_log.status == 'error' %}text-danger{% else %}text-warning{% endif %} fw-bold">{{ last_log.status.upper() }}</span> <br>
[DATA] Новых писем: {{ last_log.new_emails_count }}
{% else %}
[INFO] Запуски еще не производились.
{% endif %}
</div>
</div>
</div>
</div>
<!-- ПРАВАЯ КОЛОНКА: ТАБЛИЦА ВЕДОМСТВ -->
<div class="col-md-8">
<div class="card h-100">
<div class="card-header">СТАТИСТИКА_ВЕДОМСТВ</div>
<div class="card h-100 shadow">
<div class="card-header bg-dark border-bottom border-secondary text-success font-monospace">
[STATS] СТАТИСТИКАО_ВЕДОМСТВАМ_И_ОРГАНАМ
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<div class="table-responsive" style="max-height: 700px; overflow-y: auto;">
<table class="table table-dark table-hover mb-0 align-middle">
<thead class="sticky-top bg-dark">
<tr>
<th>Ведомство / Домен</th>
<th class="text-center">Вх.</th>
<th class="text-center">Исх.</th>
<th>Последнее письмо</th>
<th class="text-center" style="width: 100px;">Вх.</th>
<th class="text-center" style="width: 100px;">Исх.</th>
<th style="width: 180px;">Последнее письмо</th>
</tr>
</thead>
<tbody>
{% for domain in audit_data %}
<tr>
<td>
<span class="text-success">{{ domain.domain }}</span>
<br><small class="opacity-50">{{ domain.description }}</small>
<span class="text-success fw-bold">{{ domain.domain }}</span>
<br><small class="opacity-50 text-white">{{ domain.description }}</small>
</td>
<td class="text-center">{{ domain.inbound_count }}</td>
<td class="text-center">{{ domain.outbound_count }}</td>
<td>{{ domain.last_email_date.strftime('%d.%m.%Y') if domain.last_email_date else '-' }}</td>
<td class="text-center font-monospace text-success fw-bold">{{ domain.inbound_count }}</td>
<td class="text-center font-monospace text-info fw-bold">{{ domain.outbound_count }}</td>
<td class="font-monospace">{{ domain.last_email_date.strftime('%d.%m.%Y %H:%M') if domain.last_email_date else '-' }}</td>
</tr>
{% else %}
<tr>
@ -88,27 +199,29 @@
</div>
</div>
<!-- СВЕЖИЕ ПОСТУПЛЕНИЯ В АРХИВ -->
<div class="row mt-4">
<!-- СВЕЖИЕ ПОСТУПЛЕНИЯ -->
<div class="col-md-12">
<div class="card">
<div class="card-header">СВЕЖИЕОСТУПЛЕНИЯ_В_АРХИВ (ПОСЛЕДНИЕ 10)</div>
<div class="card shadow">
<div class="card-header bg-dark border-bottom border-secondary text-info font-monospace">
[FEED] СВЕЖИЕОСТУПЛЕНИЯ_В_АРХИВ (ПОСЛЕДНИЕ 10)
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0" style="font-size: 0.85rem;">
<thead>
<tr>
<th>Дата</th>
<th>Ведомство</th>
<th style="width: 180px;">Дата</th>
<th style="width: 200px;">Ведомство</th>
<th>Тема письма</th>
</tr>
</thead>
<tbody>
{% for email in latest_emails %}
<tr>
<td><code class="text-info">{{ email.date }}</code></td>
<td><span class="badge bg-secondary">{{ email.domain }}</span></td>
<td class="text-truncate" style="max-width: 500px;">{{ email.subject }}</td>
<td><code class="text-info font-monospace">{{ email.date }}</code></td>
<td><span class="badge bg-secondary font-monospace">{{ email.domain }}</span></td>
<td class="text-truncate text-white" style="max-width: 500px;">{{ email.subject }}</td>
</tr>
{% else %}
<tr>
@ -123,49 +236,7 @@
</div>
</div>
<div class="row mt-4">
<!-- КАРТОЧКА ДИНАМИЧЕСКОГО СТАТУСА ПАЙПЛАЙНА -->
<div class="col-md-6">
<div class="card border-primary" id="pipelineStatusCard" style="display: none;">
<div class="card-header d-flex justify-content-between align-items-center">
<span>>_ ТЕКУЩИЙ_СТАТУСАЙПЛАЙНА</span>
<span class="spinner-grow spinner-grow-sm text-primary" id="pipelineSpinner" role="status"></span>
</div>
<div class="card-body">
<div class="p-3 bg-black border border-primary font-monospace small">
[STEP] Шаг: <span id="pipelineStep" class="text-info">Ожидание</span> <br>
[TIME] Обновлено: <span id="pipelineTime">-</span> <br>
<div class="progress my-3" style="height: 10px; background-color: #111;">
<div id="pipelineProgress" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" role="progressbar" style="width: 0%"></div>
</div>
[DATA] Новых писем: <span id="pipelineNewEmails" class="text-success">0</span> <br>
[DATA] Всего в архиве: <span id="pipelineTotalEmails">0</span> <br>
[DATA] Размер архива: <span id="pipelineSize">0.00 MB</span>
</div>
</div>
</div>
</div>
<!-- ЛОГ ПОСЛЕДНЕГО ЗАПУСКА -->
<div class="col-md-6">
<div class="card">
<div class="card-header">ЛОГ_ПОСЛЕДНЕГО_ЗАПУСКА (БАЗА ДАННЫХ)</div>
<div class="card-body">
<div class="p-3 bg-black border border-success font-monospace small">
{% if last_log %}
[TIME] Начат: {{ fmt_local(last_log.start_time) }} <br>
[TIME] Завершен: {{ fmt_local(last_log.end_time) if last_log.end_time else 'В ПРОЦЕССЕ...' }} <br>
[STATUS] Статус: <span class="{% if last_log.status == 'success' %}text-success{% else %}text-warning{% endif %}">{{ last_log.status.upper() }}</span> <br>
[DATA] Новых писем: {{ last_log.new_emails_count }}
{% else %}
[INFO] Запуски еще не производились.
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- SCRIPTS -->
<script>
async function checkPipelineStatus() {
try {
@ -181,12 +252,44 @@ async function checkPipelineStatus() {
const size = document.getElementById('pipelineSize');
const spinner = document.getElementById('pipelineSpinner');
// Виджеты статистики
const widgetTotal = document.getElementById('statTotalEmails');
const widgetInbound = document.getElementById('statInboundEmails');
const widgetOutbound = document.getElementById('statOutboundEmails');
const widgetSize = document.getElementById('statPdfSize');
const widgetAttachments = document.getElementById('statAttachments');
const widgetLastSync = document.getElementById('statLastSync');
const widgetLastSyncTime = document.getElementById('statLastSyncTime');
// Обновляем виджеты
if (widgetTotal) widgetTotal.textContent = data.total_emails || '0';
if (widgetInbound) widgetInbound.textContent = data.total_inbound || '0';
if (widgetOutbound) widgetOutbound.textContent = data.total_outbound || '0';
if (widgetSize) widgetSize.textContent = (data.pdf_size_mb ? data.pdf_size_mb.toFixed(2) : '0.00') + ' MB';
if (widgetAttachments) widgetAttachments.textContent = data.total_attachments || '0';
if (data.last_sync_time && data.last_sync_time !== '-') {
if (widgetLastSync) {
// Извлекаем только дату из YYYY-MM-DD HH:MM:SS
const parts = data.last_sync_time.split(' ');
if (parts[0]) {
const dateParts = parts[0].split('-');
widgetLastSync.textContent = dateParts[2] + '.' + dateParts[1] + '.' + dateParts[0];
}
}
if (widgetLastSyncTime) widgetLastSyncTime.textContent = 'Время: ' + data.last_sync_time;
}
// Обновляем карту пайплайна
newEmails.textContent = data.new_emails || '0';
totalEmails.textContent = data.total_emails || '0';
size.textContent = (data.pdf_size_mb ? data.pdf_size_mb.toFixed(2) : '0.00') + ' MB';
time.textContent = data.updated_at || '-';
if (data.status === 'running') {
card.style.display = 'block';
card.className = "card border-primary h-100";
step.className = "text-info";
card.className = "card border-primary mb-4 shadow";
step.className = "text-info fw-bold";
step.textContent = data.step;
time.textContent = data.updated_at;
spinner.style.display = 'inline-block';
// Динамический прогресс
@ -197,42 +300,40 @@ async function checkPipelineStatus() {
progress.style.width = width;
progress.className = "progress-bar progress-bar-striped progress-bar-animated bg-primary";
newEmails.textContent = data.new_emails;
totalEmails.textContent = data.total_emails;
size.textContent = data.pdf_size_mb + ' MB';
// Опрашиваем часто
// Опрашиваем часто при работе
setTimeout(checkPipelineStatus, 2000);
} else if (data.status === 'success') {
card.style.display = 'block';
card.className = "card border-success h-100";
step.className = "text-success";
card.className = "card border-success mb-4 shadow";
step.className = "text-success fw-bold";
step.innerHTML = '✅ Завершен успешно';
time.textContent = data.updated_at;
spinner.style.display = 'none';
progress.style.width = '100%';
progress.className = "progress-bar bg-success";
newEmails.textContent = data.new_emails;
totalEmails.textContent = data.total_emails;
size.textContent = data.pdf_size_mb + ' MB';
// Скроем статус через 10 секунд
setTimeout(() => { card.style.display = 'none'; }, 10000);
// Опрашиваем реже в простое
setTimeout(checkPipelineStatus, 15000);
} else if (data.status === 'error' || data.status === 'stuck') {
card.style.display = 'block';
card.className = "card border-danger h-100";
step.className = "text-danger";
step.innerHTML = '❌ ' + data.step;
time.textContent = data.updated_at;
card.className = "card border-danger mb-4 shadow";
step.className = "text-danger fw-bold";
step.innerHTML = '❌ ' + (data.step || 'Ошибка выполнения');
spinner.style.display = 'none';
progress.style.width = '100%';
progress.className = "progress-bar bg-danger";
setTimeout(checkPipelineStatus, 15000);
} else {
card.style.display = 'none';
card.className = "card border-secondary mb-4 shadow";
step.className = "text-muted";
step.textContent = "Неактивен";
spinner.style.display = 'none';
progress.style.width = '0%';
progress.className = "progress-bar bg-secondary";
setTimeout(checkPipelineStatus, 15000);
}
} catch (e) {
console.error('Failed to get status:', e);
setTimeout(checkPipelineStatus, 15000);
}
}