| | | 1 | | @model OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockViewModel |
| | | 2 | | @{ |
| | 0 | 3 | | var statusUrl = Url.Content("~/Studio2/BitrixStock/sync/google-automation/status"); |
| | 0 | 4 | | var saveUrl = Url.Content("~/Studio2/BitrixStock/sync/google-automation/save"); |
| | 0 | 5 | | var historyUrl = Url.Content("~/Studio2/BitrixStock/sync/google-automation/history"); |
| | 0 | 6 | | var triggerUrl = Url.Content("~/Studio2/BitrixStock/sync/google-automation/trigger"); |
| | | 7 | | } |
| | | 8 | | |
| | | 9 | | <div class="bsx-intro-card"> |
| | | 10 | | <h6>Автоматический запуск Google Sync</h6> |
| | | 11 | | <ol class="mb-2 small ps-3"> |
| | | 12 | | <li>Задача в Hangfire запускает тот же сценарий, что и ручной `curl`: `WarehouseStockSyncJob`.</li> |
| | | 13 | | <li>При успехе Google Sync автоматически запускается `RunAllPresetsJob` (все пресеты вкладки sync).</li> |
| | | 14 | | <li>Интервал задаётся в часах (MSK), сохраняется как recurring job в Hangfire.</li> |
| | | 15 | | <li>История ниже показывает последние исполнения из Hangfire (успех/ошибка/в процессе).</li> |
| | | 16 | | </ol> |
| | | 17 | | </div> |
| | | 18 | | |
| | | 19 | | <div class="bsx-card mb-3"> |
| | | 20 | | <div class="bsx-card-header d-flex justify-content-between align-items-center flex-wrap gap-2"> |
| | | 21 | | <strong>Расписание автозапуска</strong> |
| | | 22 | | <span class="small text-muted">Recurring Job: <code>warehouse-stock-sync-google-auto</code></span> |
| | | 23 | | </div> |
| | | 24 | | <div class="card-body"> |
| | | 25 | | <div class="row g-3 align-items-end"> |
| | | 26 | | <div class="col-md-3"> |
| | | 27 | | <label class="form-label">Включено</label> |
| | | 28 | | <div class="form-check form-switch"> |
| | | 29 | | <input class="form-check-input" type="checkbox" id="googleSyncAutoEnabled" checked> |
| | | 30 | | <label class="form-check-label" for="googleSyncAutoEnabled">Активный автозапуск</label> |
| | | 31 | | </div> |
| | | 32 | | </div> |
| | | 33 | | <div class="col-md-3"> |
| | | 34 | | <label class="form-label" for="googleSyncIntervalHours">Каждые N часов</label> |
| | | 35 | | <input type="number" min="1" max="24" class="form-control" id="googleSyncIntervalHours" value="6"> |
| | | 36 | | <div class="form-text">Диапазон: 1..24</div> |
| | | 37 | | </div> |
| | | 38 | | <div class="col-md-6"> |
| | | 39 | | <div class="small text-muted" id="googleSyncStatusText">Загрузка статуса...</div> |
| | | 40 | | <div class="small text-muted" id="googleSyncCronText"></div> |
| | | 41 | | </div> |
| | | 42 | | </div> |
| | | 43 | | <div class="mt-3 d-flex flex-wrap gap-2"> |
| | | 44 | | <button type="button" class="btn btn-primary btn-sm" id="googleSyncSaveBtn"> |
| | | 45 | | <i class="fas fa-save me-1"></i>Сохранить расписание |
| | | 46 | | </button> |
| | | 47 | | <button type="button" class="btn btn-outline-success btn-sm" id="googleSyncRunNowBtn"> |
| | | 48 | | <i class="fas fa-play me-1"></i>Запустить сейчас |
| | | 49 | | </button> |
| | | 50 | | <button type="button" class="btn btn-outline-secondary btn-sm" id="googleSyncReloadBtn"> |
| | | 51 | | <i class="fas fa-sync-alt me-1"></i>Обновить |
| | | 52 | | </button> |
| | | 53 | | </div> |
| | | 54 | | </div> |
| | | 55 | | </div> |
| | | 56 | | |
| | | 57 | | <div class="bsx-card"> |
| | | 58 | | <div class="bsx-card-header d-flex justify-content-between align-items-center"> |
| | | 59 | | <strong>История вызовов (Hangfire)</strong> |
| | | 60 | | <span class="small text-muted">последние 20 запусков</span> |
| | | 61 | | </div> |
| | | 62 | | <div class="card-body p-2"> |
| | | 63 | | <div class="bsx-grid-toolbar mb-2"> |
| | | 64 | | <div class="bsx-grid-search"> |
| | | 65 | | <i class="fas fa-search"></i> |
| | | 66 | | <input type="search" id="googleSyncHistorySearch" placeholder="JobId, статус, ошибка…" autocomplete="off |
| | | 67 | | </div> |
| | | 68 | | <span class="bsx-grid-count" id="googleSyncHistoryCount"></span> |
| | | 69 | | </div> |
| | | 70 | | <div id="googleSyncHistoryTable" class="bsx-grid bsx-grid--modal"></div> |
| | | 71 | | </div> |
| | | 72 | | </div> |
| | | 73 | | |
| | | 74 | | <script> |
| | | 75 | | (() => { |
| | 0 | 76 | | const statusUrl = '@statusUrl'; |
| | 0 | 77 | | const saveUrl = '@saveUrl'; |
| | 0 | 78 | | const historyUrl = '@historyUrl'; |
| | 0 | 79 | | const triggerUrl = '@triggerUrl'; |
| | | 80 | | const enabledEl = document.getElementById('googleSyncAutoEnabled'); |
| | | 81 | | const hoursEl = document.getElementById('googleSyncIntervalHours'); |
| | | 82 | | const statusEl = document.getElementById('googleSyncStatusText'); |
| | | 83 | | const cronEl = document.getElementById('googleSyncCronText'); |
| | | 84 | | // История запусков Hangfire — грид AG Grid (сортировка, фильтр по статусу, поиск). |
| | | 85 | | const historyTable = window.BsxGrid?.localTable('googleSyncHistoryTable', { |
| | | 86 | | key: 'googleSync', |
| | | 87 | | emptyText: 'Запусков пока нет', |
| | | 88 | | quickSearch: document.getElementById('googleSyncHistorySearch'), |
| | | 89 | | countEl: document.getElementById('googleSyncHistoryCount'), |
| | | 90 | | columnDefs: [ |
| | | 91 | | { field: 'jobId', headerName: 'JobId', width: 110, cellRenderer: p => `<code>${esc(p.value)}</code>` }, |
| | | 92 | | { field: 'status', headerName: 'Статус', width: 140, filter: window.BsxGrid?.SetFilter, floatingFilterCompon |
| | | 93 | | cellRenderer: p => p.value === 'Succeeded' ? '<span class="badge bg-success">Success</span>' |
| | | 94 | | : p.value === 'Failed' ? '<span class="badge bg-danger">Failed</span>' |
| | | 95 | | : '<span class="badge bg-warning text-dark">Processing</span>' }, |
| | | 96 | | { field: 'startedAt', headerName: 'Старт (UTC)', width: 160, sort: 'desc', valueFormatter: p => formatDt(p.v |
| | | 97 | | { field: 'finishedAt', headerName: 'Финиш (UTC)', width: 160, valueFormatter: p => formatDt(p.value) }, |
| | | 98 | | { field: 'durationMs', headerName: 'Длительность', width: 130, type: 'numericColumn', filter: 'agNumberColum |
| | | 99 | | { field: 'error', headerName: 'Ошибка', flex: 1, minWidth: 220, cellClass: 'text-danger small', tooltipField |
| | | 100 | | ] |
| | | 101 | | }); |
| | | 102 | | if (!enabledEl || !hoursEl || !statusEl || !cronEl) return; |
| | | 103 | | |
| | | 104 | | const formatDt = (value) => value ? new Date(value).toISOString().replace('T', ' ').slice(0, 19) : '—'; |
| | | 105 | | const formatDuration = (ms) => Number.isFinite(ms) ? `${Math.round(ms)} ms` : '—'; |
| | | 106 | | const esc = (s) => (s ?? '').toString() |
| | | 107 | | .replaceAll('&', '&') |
| | | 108 | | .replaceAll('<', '<') |
| | | 109 | | .replaceAll('>', '>') |
| | | 110 | | .replaceAll('"', '"'); |
| | | 111 | | const parseJsonSafe = async (response) => { |
| | | 112 | | const raw = await response.text(); |
| | | 113 | | if (!raw || !raw.trim()) { |
| | | 114 | | return null; |
| | | 115 | | } |
| | | 116 | | try { |
| | | 117 | | return JSON.parse(raw); |
| | | 118 | | } catch { |
| | | 119 | | throw new Error(`Некорректный JSON в ответе (${response.status}).`); |
| | | 120 | | } |
| | | 121 | | }; |
| | | 122 | | const ensureOkJson = async (response, fallbackError) => { |
| | | 123 | | const data = await parseJsonSafe(response); |
| | | 124 | | if (!response.ok) { |
| | | 125 | | throw new Error(data?.error || fallbackError || `HTTP ${response.status}`); |
| | | 126 | | } |
| | | 127 | | if (!data) { |
| | | 128 | | throw new Error('Пустой ответ сервера.'); |
| | | 129 | | } |
| | | 130 | | return data; |
| | | 131 | | }; |
| | | 132 | | const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => { |
| | | 133 | | const controller = new AbortController(); |
| | | 134 | | const timer = setTimeout(() => controller.abort(), timeoutMs); |
| | | 135 | | try { |
| | | 136 | | return await fetch(url, { ...options, signal: controller.signal }); |
| | | 137 | | } catch (e) { |
| | | 138 | | if (e?.name === 'AbortError') { |
| | | 139 | | throw new Error('Сервер долго не отвечает (таймаут 15с). Попробуйте ещё раз.'); |
| | | 140 | | } |
| | | 141 | | throw e; |
| | | 142 | | } finally { |
| | | 143 | | clearTimeout(timer); |
| | | 144 | | } |
| | | 145 | | }; |
| | | 146 | | |
| | | 147 | | async function loadStatus() { |
| | | 148 | | const response = await fetchWithTimeout(statusUrl, { credentials: 'same-origin' }); |
| | | 149 | | const data = await ensureOkJson(response, 'Не удалось загрузить статус'); |
| | | 150 | | if (!data?.success) throw new Error(data?.error || 'Не удалось загрузить статус'); |
| | | 151 | | enabledEl.checked = !!data.isEnabled; |
| | | 152 | | hoursEl.value = String(data.intervalHours || 6); |
| | | 153 | | statusEl.textContent = data.isEnabled |
| | | 154 | | ? `Активно. Следующий запуск: ${formatDt(data.nextExecution)}` |
| | | 155 | | : 'Автозапуск отключён'; |
| | | 156 | | cronEl.textContent = data.cronExpression |
| | | 157 | | ? `Cron: ${data.cronExpression}; последний статус: ${data.lastJobState || '—'}` |
| | | 158 | | : 'Cron не задан'; |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | async function loadHistory() { |
| | | 162 | | const response = await fetchWithTimeout(`${historyUrl}?take=20`, { credentials: 'same-origin' }); |
| | | 163 | | const data = await ensureOkJson(response, 'Не удалось загрузить историю'); |
| | | 164 | | if (!data?.success) throw new Error(data?.error || 'Не удалось загрузить историю'); |
| | | 165 | | const items = Array.isArray(data.items) ? data.items : []; |
| | | 166 | | historyTable?.setRows(items); |
| | | 167 | | } |
| | | 168 | | |
| | | 169 | | const runWithBar = (fn) => |
| | | 170 | | (typeof PageLoadingBar !== 'undefined' && PageLoadingBar.run) |
| | | 171 | | ? PageLoadingBar.run(fn) |
| | | 172 | | : fn(); |
| | | 173 | | |
| | | 174 | | async function saveSchedule() { |
| | | 175 | | const hours = Number.parseInt(hoursEl.value, 10); |
| | | 176 | | if (!Number.isInteger(hours) || hours < 1 || hours > 24) { |
| | | 177 | | await St2Dialog.alert('Интервал должен быть целым числом от 1 до 24.'); |
| | | 178 | | hoursEl.focus(); |
| | | 179 | | return; |
| | | 180 | | } |
| | | 181 | | await runWithBar(async () => { |
| | | 182 | | const response = await fetchWithTimeout(saveUrl, { |
| | | 183 | | method: 'POST', |
| | | 184 | | credentials: 'same-origin', |
| | | 185 | | headers: { 'Content-Type': 'application/json' }, |
| | | 186 | | body: JSON.stringify({ isEnabled: enabledEl.checked, intervalHours: hours }) |
| | | 187 | | }, 15000); |
| | | 188 | | const data = await ensureOkJson(response, 'Ошибка сохранения'); |
| | | 189 | | if (!response.ok || !data?.success) throw new Error(data?.error || 'Ошибка сохранения'); |
| | | 190 | | }); |
| | | 191 | | try { |
| | | 192 | | await runWithBar(async () => { |
| | | 193 | | await Promise.all([loadStatus(), loadHistory()]); |
| | | 194 | | }); |
| | | 195 | | } catch { |
| | | 196 | | // ошибка обновления статуса не отменяет факт успешного сохранения |
| | | 197 | | } |
| | | 198 | | St2Dialog.toast('Расписание сохранено', 'success'); |
| | | 199 | | } |
| | | 200 | | |
| | | 201 | | async function runNow() { |
| | | 202 | | await runWithBar(async () => { |
| | | 203 | | const response = await fetchWithTimeout(triggerUrl, { method: 'POST', credentials: 'same-origin' }, 15000); |
| | | 204 | | const data = await ensureOkJson(response, 'Ошибка запуска'); |
| | | 205 | | if (!response.ok || !data?.success) throw new Error(data?.error || 'Ошибка запуска'); |
| | | 206 | | await loadHistory(); |
| | | 207 | | }); |
| | | 208 | | } |
| | | 209 | | |
| | | 210 | | document.getElementById('googleSyncSaveBtn')?.addEventListener('click', async () => { |
| | | 211 | | try { await saveSchedule(); } |
| | | 212 | | catch (e) { await St2Dialog.alert(e?.message || 'Ошибка сохранения'); } |
| | | 213 | | }); |
| | | 214 | | document.getElementById('googleSyncRunNowBtn')?.addEventListener('click', async () => { |
| | | 215 | | try { await runNow(); } |
| | | 216 | | catch (e) { await St2Dialog.alert(e?.message || 'Ошибка запуска'); } |
| | | 217 | | }); |
| | | 218 | | document.getElementById('googleSyncReloadBtn')?.addEventListener('click', async () => { |
| | | 219 | | try { |
| | | 220 | | await runWithBar(async () => { |
| | | 221 | | await Promise.all([loadStatus(), loadHistory()]); |
| | | 222 | | }); |
| | | 223 | | } catch (e) { |
| | | 224 | | await St2Dialog.alert(e?.message || 'Ошибка обновления'); |
| | | 225 | | } |
| | | 226 | | }); |
| | | 227 | | |
| | | 228 | | (async () => { |
| | | 229 | | try { |
| | | 230 | | await Promise.all([loadStatus(), loadHistory()]); |
| | | 231 | | } catch (e) { |
| | | 232 | | statusEl.textContent = e?.message || 'Ошибка загрузки'; |
| | | 233 | | historyTable?.message('Не удалось загрузить историю'); |
| | | 234 | | } |
| | | 235 | | })(); |
| | | 236 | | })(); |
| | | 237 | | </script> |