const Monitoring = { repo: 'polinacherpovitskaya-glitch/ro-calculator', actionsUrl: 'https://github.com/polinacherpovitskaya-glitch/ro-calculator/actions', refreshMs: 120000, cacheTtlMs: 120000, cacheKey: 'ro_monitoring_cache_v1', _refreshTimer: null, state: { loading: false, error: '', workflows: [], fetchedAt: null, fromCache: false, rateLimitRemaining: null, rateLimitResetAt: null, }, workflowMeta: [ { name: 'Deploy GitHub Pages', shortLabel: 'Deploy', description: 'Публикация GitHub Pages' }, { name: 'Live site smoke', shortLabel: 'Live smoke', description: 'Проверка bugs / warehouse / monitoring на live-сайте' }, { name: 'Warehouse stress smoke', shortLabel: 'Warehouse stress', description: 'Критичные складские и заказные сценарии' }, ], load() { this.ensureAutoRefresh(); this.render(); this.refresh({ force: false }).catch((error) => { console.warn('[Monitoring] refresh failed:', error); }); }, ensureAutoRefresh() { if (this._refreshTimer) { clearInterval(this._refreshTimer); } this._refreshTimer = setInterval(() => { if (typeof App !== 'undefined' && App.currentPage !== 'monitoring') { return; } this.refresh({ force: true, silent: true }).catch((error) => { console.warn('[Monitoring] auto refresh failed:', error); }); }, this.refreshMs); }, async forceRefresh() { await this.refresh({ force: true }); }, async refresh({ force = false, silent = false } = {}) { if (this.state.loading) return; const cached = !force ? this.readCache() : null; if (cached) { this.state = { ...this.state, ...cached, loading: false, error: '', fromCache: true, }; this.render(); } else if (!silent) { this.state.loading = true; this.state.error = ''; this.render(); } try { this.state.loading = true; this.render(); const response = await fetch(`https://api.github.com/repos/${this.repo}/actions/runs?per_page=50`, { headers: { Accept: 'application/vnd.github+json', }, cache: 'no-store', }); const rateLimitRemaining = response.headers.get('x-ratelimit-remaining'); const rateLimitReset = response.headers.get('x-ratelimit-reset'); if (!response.ok) { throw new Error(`GitHub API ${response.status}`); } const payload = await response.json(); const runs = Array.isArray(payload.workflow_runs) ? payload.workflow_runs : []; const workflows = this.workflowMeta.map(meta => this.normalizeRun(meta, this.pickLatestRun(meta.name, runs))); this.state = { loading: false, error: '', workflows, fetchedAt: new Date().toISOString(), fromCache: false, rateLimitRemaining: rateLimitRemaining != null ? Number(rateLimitRemaining) : null, rateLimitResetAt: rateLimitReset ? new Date(Number(rateLimitReset) * 1000).toISOString() : null, }; this.writeCache(this.state); } catch (error) { const fallback = cached || this.readCache(); this.state = { ...this.state, ...(fallback || {}), loading: false, error: fallback ? `GitHub Actions сейчас не ответил (${error.message}). Показываю сохранённый снимок.` : `GitHub Actions сейчас не ответил (${error.message}).`, fromCache: !!fallback, }; } finally { this.state.loading = false; this.render(); } }, readCache() { try { const raw = localStorage.getItem(this.cacheKey); if (!raw) return null; const parsed = JSON.parse(raw); if (!parsed?.fetchedAt) return null; const ageMs = Date.now() - new Date(parsed.fetchedAt).getTime(); if (Number.isNaN(ageMs) || ageMs > this.cacheTtlMs) { return null; } return parsed; } catch (error) { return null; } }, writeCache(snapshot) { try { localStorage.setItem(this.cacheKey, JSON.stringify(snapshot)); } catch (error) { console.warn('[Monitoring] cache write failed:', error); } }, pickLatestRun(name, runs) { const matches = runs .filter(run => String(run?.name || '') === String(name)) .sort((a, b) => { const aTime = new Date(a?.created_at || 0).getTime(); const bTime = new Date(b?.created_at || 0).getTime(); return bTime - aTime; }); return matches[0] || null; }, normalizeRun(meta, run) { const status = String(run?.status || ''); const conclusion = String(run?.conclusion || ''); const key = this.statusKey(status, conclusion, !!run); return { ...meta, id: run?.id || null, found: !!run, status, conclusion, statusKey: key, statusLabel: this.statusLabel(key), statusColor: this.statusColor(key), htmlUrl: run?.html_url || '', event: run?.event || '', branch: run?.head_branch || '', sha: run?.head_sha ? String(run.head_sha).slice(0, 7) : '', runNumber: run?.run_number || null, createdAt: run?.created_at || null, updatedAt: run?.updated_at || null, actor: run?.actor?.login || '', title: run?.display_title || '', }; }, statusKey(status, conclusion, found) { if (!found) return 'missing'; if (status && status !== 'completed') return 'running'; if (conclusion === 'success') return 'success'; if (conclusion === 'failure' || conclusion === 'timed_out' || conclusion === 'startup_failure') return 'failure'; if (conclusion === 'cancelled' || conclusion === 'action_required') return 'warning'; return 'neutral'; }, statusLabel(key) { switch (key) { case 'success': return 'Успешно'; case 'failure': return 'Ошибка'; case 'warning': return 'Нужна проверка'; case 'running': return 'Идёт'; case 'missing': return 'Нет данных'; default: return 'Неизвестно'; } }, statusColor(key) { switch (key) { case 'success': return '#166534'; case 'failure': return '#b91c1c'; case 'warning': return '#92400e'; case 'running': return '#1d4ed8'; case 'missing': return '#475569'; default: return '#475569'; } }, esc(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, formatDateTime(value) { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '—'; return new Intl.DateTimeFormat('ru-RU', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(date); }, formatAge(value) { if (!value) return '—'; const diffMs = Date.now() - new Date(value).getTime(); if (!Number.isFinite(diffMs)) return '—'; const minutes = Math.round(diffMs / 60000); if (minutes < 1) return 'только что'; if (minutes < 60) return `${minutes} мин назад`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours} ч назад`; const days = Math.round(hours / 24); return `${days} дн назад`; }, overallStatus() { const items = this.state.workflows || []; if (!items.length) { return { label: this.state.loading ? 'Обновляем…' : 'Нет данных', color: '#475569', note: 'Пока нет данных по workflow.' }; } if (items.some(item => item.statusKey === 'failure' || item.statusKey === 'warning' || item.statusKey === 'missing')) { return { label: 'Нужна проверка', color: '#b91c1c', note: 'Есть workflow, которому нужно внимание.' }; } if (items.some(item => item.statusKey === 'running')) { return { label: 'Идёт проверка', color: '#1d4ed8', note: 'Часть проверок сейчас выполняется.' }; } return { label: 'Всё спокойно', color: '#166534', note: 'Последние deploy и smoke-проверки зелёные.' }; }, eventLabel(event) { switch (event) { case 'workflow_run': return 'после deploy'; case 'workflow_dispatch': return 'вручную'; case 'schedule': return 'по расписанию'; case 'push': return 'push'; default: return event || '—'; } }, renderSummaryCard() { const summary = this.overallStatus(); const fetched = this.state.fetchedAt ? `${this.formatDateTime(this.state.fetchedAt)} · ${this.formatAge(this.state.fetchedAt)}` : '—'; const badge = this.state.fromCache ? 'Снимок из кеша' : 'Свежие данные'; return `
${this.state.loading ? 'Собираем статусы workflow…' : 'Пока нет данных по workflow.'}