< Summary

Information
Class: AspNetCoreGeneratedDocument.Areas_Studio2_Views_BitrixStock__SyncGoogleAutomationTab
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Views/BitrixStock/_SyncGoogleAutomationTab.cshtml
Line coverage
0%
Covered lines: 0
Uncovered lines: 8
Coverable lines: 8
Total lines: 237
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ExecuteAsync()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Views/BitrixStock/_SyncGoogleAutomationTab.cshtml

#LineLine coverage
 1@model OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockViewModel
 2@{
 03    var statusUrl = Url.Content("~/Studio2/BitrixStock/sync/google-automation/status");
 04    var saveUrl = Url.Content("~/Studio2/BitrixStock/sync/google-automation/save");
 05    var historyUrl = Url.Content("~/Studio2/BitrixStock/sync/google-automation/history");
 06    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(() => {
 076    const statusUrl = '@statusUrl';
 077    const saveUrl = '@saveUrl';
 078    const historyUrl = '@historyUrl';
 079    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('&', '&amp;')
 108        .replaceAll('<', '&lt;')
 109        .replaceAll('>', '&gt;')
 110        .replaceAll('"', '&quot;');
 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>

Methods/Properties

ExecuteAsync()