< Summary

Information
Class: AspNetCoreGeneratedDocument.Areas_Studio2_Views_Job_Index
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Views/Job/Index.cshtml
Line coverage
0%
Covered lines: 0
Uncovered lines: 36
Coverable lines: 36
Total lines: 807
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 82
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ExecuteAsync()0%8556920%
<ExecuteAsync()100%210%
<ExecuteAsync()100%210%
<ExecuteAsync()0%4260%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Views/Job/Index.cshtml

#LineLine coverage
 1@using OzonOrdersWeb.ViewModels.JobViewModels
 2@model JobResultViewModel
 3
 4@{
 05    ViewData["Title"] = "Фоновые задачи";
 06    Layout = "~/Views/Shared/_Layout.cshtml";
 07    var schedules = Model.Schedules ?? new List<JobScheduleViewModel>();
 08    var hq = Model.HangfireQueue;
 9}
 10
 11<link rel="stylesheet" href="~/css/Studio2/Job/job-index.css" asp-append-version="true"/>
 12<link rel="stylesheet" href="~/css/Studio2/HangfireQueue/hangfire-jobs-partials.css" asp-append-version="true"/>
 13
 014@await Html.PartialAsync("_BsxGridShell")
 15
 16<div class="container-fluid py-3 bsx-surface job-page">
 17
 18    <!-- Hero -->
 19    <div class="app-section-hero app-section-hero--import d-flex flex-wrap justify-content-between align-items-start gap
 20        <div>
 21            <h1><i class="fas fa-cogs me-2 text-primary"></i>Фоновые задачи</h1>
 22            <div class="subtitle">Расписания Hangfire, очередь выполнения и разовые операции</div>
 23            <div class="subtitle-help">
 24                Здесь настраиваются <strong>recurring jobs</strong> — автозагрузка заказов с Ozon/Yandex/файлов,
 25                обновление статусов, возвраты и синхронизация чатов. Расписания сохраняются в Hangfire (cron, MSK).
 26                Разовый запуск «Обновление статусов» — для выбранного периода <code>ProcessingDate</code> с прогрессом в
 27            </div>
 28            <div class="app-section-hero__quick-links">
 29                <a href="/hangfire" target="_blank" rel="noopener"><i class="fas fa-external-link-alt"></i>Hangfire Dash
 30                <a asp-area="Studio2" asp-controller="JobLogs" asp-action="Index"><i class="fas fa-list-alt"></i>Логи за
 31                <a asp-area="Studio2" asp-controller="TransactionJobs" asp-action="Index"><i class="fas fa-robot"></i>Ав
 32            </div>
 33        </div>
 34        <div class="d-flex gap-2 align-items-center flex-wrap">
 035            @if (!string.IsNullOrEmpty(Model.PeriodStatusUpdateState))
 36            {
 037                if (Model.PeriodStatusUpdateState == "processing")
 38                {
 39                    <span class="badge bg-warning text-dark fs-6 py-2 px-3">
 40                        <i class="fas fa-sync-alt fa-spin me-1"></i>Обновление статусов выполняется
 041                        @if (!string.IsNullOrEmpty(Model.PeriodStatusUpdateJobId))
 42                        {
 043                            <span class="opacity-75 ms-1 small">@Model.PeriodStatusUpdateJobId</span>
 44                        }
 45                    </span>
 46                }
 047                else if (Model.PeriodStatusUpdateState == "enqueued")
 48                {
 49                    <span class="badge bg-info text-dark fs-6 py-2 px-3">
 50                        <i class="fas fa-clock me-1"></i>В очереди
 51                    </span>
 52                }
 53            }
 54        </div>
 55    </div>
 56
 57    <div class="job-intro">
 58        <i class="fas fa-circle-info"></i>
 59        <div class="job-intro-text">
 60            <strong>Три вкладки:</strong> «Расписания» — cron и формы сохранения; «Очередь Hangfire» — что выполняется с
 61            «Результаты» — последние succeeded-задачи. Кнопка «Сохранить» сразу перерегистрирует recurring job в Hangfir
 62            (часть действий перенаправляет в <a href="/hangfire/recurring" target="_blank" rel="noopener">dashboard</a>)
 63        </div>
 64    </div>
 65
 66    <!-- Разовое обновление статусов -->
 67    <div class="job-card">
 68        <div class="job-card-head">
 69            <h2 class="job-card-title">
 70                <i class="fas fa-bolt"></i>Разовое обновление статусов заказов
 71            </h2>
 72        </div>
 73        <div class="job-card-body">
 74            <p class="job-section-lead mb-3">
 75                Не путать с recurring <code>StatusUpdateJob</code> ниже — это ручной запуск за произвольный период.
 76                Система найдёт все <strong>неархивные</strong> заказы с <code>ProcessingDate</code> в диапазоне и обнови
 77                <code>ClientStatus</code> через API Ozon. Прогресс — в модальном окне (SignalR).
 78            </p>
 79            <form id="formStatusUpdateByPeriod" asp-area="Studio2" asp-controller="Job" method="post"
 80                  asp-action="UpdateStatusesByProcessingDate"
 81                  class="row g-2 align-items-end">
 082                @Html.AntiForgeryToken()
 83                <div class="col-sm-4 col-md-3">
 84                    <label class="form-label small fw-semibold text-secondary" for="statusFrom">С даты</label>
 85                    <input type="date" class="form-control form-control-sm" name="from" id="statusFrom" required/>
 86                    <div class="job-field-hint">ProcessingDate ≥</div>
 87                </div>
 88                <div class="col-sm-4 col-md-3">
 89                    <label class="form-label small fw-semibold text-secondary" for="statusTo">По дату</label>
 90                    <input type="date" class="form-control form-control-sm" name="to" id="statusTo" required/>
 91                    <div class="job-field-hint">ProcessingDate ≤ (включительно)</div>
 92                </div>
 93                <div class="col-sm-4 col-md-3">
 94                    <button type="submit" class="btn btn-primary btn-sm w-100" id="btnStatusUpdateSubmit">
 95                        <i class="fas fa-play me-1"></i>Запустить
 96                    </button>
 97                    <div class="job-field-hint text-center">Один запуск за раз</div>
 98                </div>
 99            </form>
 100            <div class="job-workflow mt-3">
 101                <div class="job-workflow-step"><span class="num">1</span>Укажите период дат</div>
 102                <div class="job-workflow-step"><span class="num">2</span>Запуск → модалка с логом</div>
 103                <div class="job-workflow-step"><span class="num">3</span>Дождитесь 100% или ошибки</div>
 104            </div>
 105        </div>
 106    </div>
 107
 108    <!-- Вкладки -->
 109    <ul class="nav nav-tabs mb-0" id="jobPageTabs" role="tablist">
 110        <li class="nav-item" role="presentation">
 111            <button class="nav-link @(Model.ActivePageTab == "schedules" ? "active" : "")"
 112                    id="tab-schedules-btn" data-bs-toggle="tab" data-bs-target="#tab-schedules"
 113                    type="button" role="tab">
 114                <i class="fas fa-calendar-alt me-1"></i>Расписания
 115            </button>
 116        </li>
 117        <li class="nav-item" role="presentation">
 118            <button class="nav-link @(Model.ActivePageTab == "queue" ? "active" : "")"
 119                    id="tab-queue-btn" data-bs-toggle="tab" data-bs-target="#tab-queue"
 120                    type="button" role="tab">
 121                <i class="fas fa-stream me-1"></i>Очередь Hangfire
 122            </button>
 123        </li>
 124        <li class="nav-item" role="presentation">
 125            <button class="nav-link @(Model.ActivePageTab == "results" ? "active" : "")"
 126                    id="tab-results-btn" data-bs-toggle="tab" data-bs-target="#tab-results"
 127                    type="button" role="tab">
 128                <i class="fas fa-list-alt me-1"></i>Результаты
 129            </button>
 130        </li>
 131    </ul>
 132
 133    <div class="job-tab-hint" id="jobTabHint">
 0134        @if (Model.ActivePageTab == "queue")
 135        {
 136            <text>Мониторинг очереди Hangfire: enqueued, processing, succeeded. Для детальной диагностики — Hangfire Das
 137        }
 0138        else if (Model.ActivePageTab == "results")
 139        {
 140            <text>Последние 50 succeeded-задач с текстовым результатом. Полная история — во вкладке «Выполненные» очеред
 141        }
 142        else
 143        {
 144            <text>Текущее состояние recurring jobs и формы настройки cron. Время — московское. «Сохранить» обновляет рас
 145        }
 146    </div>
 147
 148    <div class="tab-content job-card" style="border-top-left-radius: 0; border-top: none;">
 149
 150        <!-- Вкладка: Расписания -->
 151        <div class="tab-pane fade @(Model.ActivePageTab == "schedules" ? "show active" : "")"
 152             id="tab-schedules" role="tabpanel">
 153            <div class="job-card-body">
 154
 155                <!-- Таблица текущего состояния -->
 156                <div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-2">
 157                    <div>
 158                        <span class="fw-semibold">Статус recurring-задач в Hangfire</span>
 159                        <span class="text-muted small ms-2">последний и следующий запуск по московскому времени</span>
 160                    </div>
 161                    <a class="btn btn-sm btn-outline-secondary"
 162                       href="@Url.Action("Index", "Job", new { area = "Studio2", pageTab = "schedules" })">
 163                        <i class="fas fa-rotate me-1"></i>Обновить
 164                    </a>
 165                </div>
 166
 167                <div class="bsx-dir-card mb-4">
 168                    <div class="bsx-dir-body">
 169                        <div id="jobSchedulesGrid" class="bsx-grid"></div>
 170                    </div>
 171                </div>
 0172                <script type="application/json" id="jobSchedulesData">@Html.Raw(System.Text.Json.JsonSerializer.Serializ
 173
 174                <!-- Формы настройки расписаний -->
 175                <div class="fw-semibold mb-1 text-secondary small text-uppercase" style="letter-spacing:.05em;">Настройк
 176                <p class="job-section-lead">Измените параметры и нажмите «Сохранить» — Hangfire пересоздаст recurring jo
 177                <div class="job-schedule-stack">
 178
 179                    <!-- Ежедневное обновление -->
 180                    <section class="job-panel">
 181                        <div class="job-panel__head">
 182                            <i class="fas fa-calendar-day"></i>Ежедневное обновление заказов (EverydayUpdate)
 183                        </div>
 184                        <div class="job-panel__body">
 185                            <p class="job-panel__desc">
 186                                Загружает заказы всех Ozon-клиентов за указанное количество месяцев через <code>/v1/post
 187                                Запускается раз в сутки в заданное время (московское).
 188                            </p>
 189                            <form asp-area="Studio2" asp-controller="Job" method="post" asp-action="SetEverydayUpdate"
 190                                  class="row g-2 align-items-end">
 191                                <div class="col-sm-6 col-md-4">
 192                                    <label class="form-label small fw-semibold text-secondary">Период</label>
 193                                    <div class="job-inline">
 194                                        <input type="number" class="form-control form-control-sm input-number" name="mon
 195                                               min="1" max="3" value="3"/>
 196                                        <span class="job-inline-label">мес.</span>
 197                                    </div>
 198                                    <div class="job-panel__hint">Глубина загрузки FBS-отправлений</div>
 199                                </div>
 200                                <div class="col-sm-6 col-md-4">
 201                                    <label class="form-label small fw-semibold text-secondary">Время запуска (MSK)</labe
 202                                    <div class="job-inline">
 203                                        <input type="number" class="form-control form-control-sm input-time" name="updat
 204                                               id="updateHour" min="0" max="23" value="5"/>
 205                                        <span class="job-inline-label">:</span>
 206                                        <input type="number" class="form-control form-control-sm input-time" name="updat
 207                                               id="updateMinute" min="0" max="59" value="0"/>
 208                                    </div>
 209                                    <div class="job-panel__hint">Cron: ежедневно в это время</div>
 210                                </div>
 211                                <div class="col-sm-12 col-md-4">
 212                                    <button type="submit" class="btn btn-outline-primary btn-sm w-100">
 213                                        <i class="fas fa-save me-1"></i>Сохранить
 214                                    </button>
 215                                </div>
 216                            </form>
 217                        </div>
 218                    </section>
 219
 220                    <!-- Загрузка возвратов -->
 221                    <section class="job-panel">
 222                        <div class="job-panel__head">
 223                            <i class="fas fa-undo-alt"></i>Загрузка возвратов Ozon (LoadReturnsJob)
 224                        </div>
 225                        <div class="job-panel__body">
 226                            <p class="job-panel__desc">
 227                                Периодически загружает возвраты через <code>/v1/returns/list</code> по периоду изменения
 228                                и обновляет/создаёт записи в БД (upsert). Тот же механизм, что и на странице <code>/Orde
 229                            </p>
 230                            <form asp-area="Studio2" asp-controller="Job" method="post" asp-action="SetLoadReturnsRecurr
 231                                  class="row g-2 align-items-end">
 232                                <div class="col-sm-6 col-md-4">
 233                                    <label class="form-label small fw-semibold text-secondary">Интервал</label>
 234                                    <div class="job-inline">
 235                                        <input type="number" class="form-control form-control-sm input-number" name="del
 236                                               min="1" max="60" value="15"/>
 237                                        <span class="job-inline-label">мин.</span>
 238                                    </div>
 239                                    <div class="job-panel__hint">Как часто опрашивать Ozon API</div>
 240                                </div>
 241                                <div class="col-sm-6 col-md-4">
 242                                    <label class="form-label small fw-semibold text-secondary">Период</label>
 243                                    <div class="job-inline">
 244                                        <input type="number" class="form-control form-control-sm input-number" name="mon
 245                                               min="1" max="12" value="3"/>
 246                                        <span class="job-inline-label">мес.</span>
 247                                    </div>
 248                                    <div class="job-panel__hint">Глубина по дате изменения возврата</div>
 249                                </div>
 250                                <div class="col-sm-12 col-md-4">
 251                                    <button type="submit" class="btn btn-outline-primary btn-sm w-100">
 252                                        <i class="fas fa-save me-1"></i>Сохранить
 253                                    </button>
 254                                </div>
 255                            </form>
 256                        </div>
 257                    </section>
 258
 259                    <!-- Обновление статусов -->
 260                    <section class="job-panel">
 261                        <div class="job-panel__head">
 262                            <i class="fas fa-sync-alt"></i>Периодическое обновление статусов (StatusUpdateJob)
 263                        </div>
 264                        <div class="job-panel__body">
 265                            <p class="job-panel__desc">
 266                                Берёт последние <em>N</em> неархивных заказов (по <code>ProcessingDate</code>) и обновля
 267                                <code>ClientStatus</code> через API Ozon. Удобно для поддержания актуальности статусов н
 268                            </p>
 269                            <form asp-area="Studio2" asp-controller="Job" method="post" asp-action="SetStatusUpdateRecur
 270                                  class="row g-2 align-items-end">
 271                                <div class="col-sm-6 col-md-4">
 272                                    <label class="form-label small fw-semibold text-secondary">Интервал</label>
 273                                    <div class="job-inline">
 274                                        <input type="number" class="form-control form-control-sm input-number" name="del
 275                                               min="1" max="60" value="15"/>
 276                                        <span class="job-inline-label">мин.</span>
 277                                    </div>
 278                                </div>
 279                                <div class="col-sm-6 col-md-4">
 280                                    <label class="form-label small fw-semibold text-secondary">Кол-во заказов</label>
 281                                    <input type="number" class="form-control form-control-sm input-number" name="count"
 282                                           min="1" max="1000" value="50"/>
 283                                    <div class="job-panel__hint">Последние N заказов по ProcessingDate</div>
 284                                </div>
 285                                <div class="col-sm-12 col-md-4">
 286                                    <button type="submit" class="btn btn-outline-success btn-sm w-100">
 287                                        <i class="fas fa-save me-1"></i>Сохранить
 288                                    </button>
 289                                </div>
 290                            </form>
 291                        </div>
 292                    </section>
 293
 294                    <!-- Платформы и файлы -->
 295                    <section class="job-panel">
 296                        <div class="job-panel__head">
 297                            <i class="fas fa-store"></i>Периодическое обновление по платформам и файлам
 298                        </div>
 299                        <div class="job-panel__body">
 300                            <p class="job-panel__desc">
 301                                Загружает новые заказы с каждой торговой площадки с заданным интервалом.
 302                                Файлы берутся из Dropbox (<code>/Orders</code>) и парсятся по настройкам сопоставления с
 303                            </p>
 304                            <div class="d-flex flex-column gap-2">
 305
 306                                <div class="job-platform-row">
 307                                    <form asp-area="Studio2" asp-controller="Job" method="post" asp-action="SetOzonRecur
 308                                          class="row g-2 align-items-center">
 309                                        <div class="col-auto">
 310                                            <span class="job-platform-badge bg-primary">OZON</span>
 311                                        </div>
 312                                        <div class="col-auto">
 313                                            <div class="job-inline">
 314                                                <input type="number" class="form-control form-control-sm input-number" n
 315                                                       min="1" max="60" value="15"/>
 316                                                <span class="job-inline-label">мин.</span>
 317                                            </div>
 318                                        </div>
 319                                        <div class="col-auto">
 320                                            <button type="submit" class="btn btn-outline-primary btn-sm">
 321                                                <i class="fas fa-save me-1"></i>Сохранить
 322                                            </button>
 323                                        </div>
 324                                        <div class="col text-muted" style="font-size:0.75rem;">Заказы FBS от всех Ozon-к
 325                                    </form>
 326                                </div>
 327
 328                                <div class="job-platform-row">
 329                                    <form asp-area="Studio2" asp-controller="Job" method="post" asp-action="SetYandexRec
 330                                          class="row g-2 align-items-center">
 331                                        <div class="col-auto">
 332                                            <span class="job-platform-badge bg-warning text-dark">YANDEX</span>
 333                                        </div>
 334                                        <div class="col-auto">
 335                                            <div class="job-inline">
 336                                                <input type="number" class="form-control form-control-sm input-number" n
 337                                                       min="1" max="60" value="15"/>
 338                                                <span class="job-inline-label">мин.</span>
 339                                            </div>
 340                                        </div>
 341                                        <div class="col-auto">
 342                                            <button type="submit" class="btn btn-outline-warning btn-sm">
 343                                                <i class="fas fa-save me-1"></i>Сохранить
 344                                            </button>
 345                                        </div>
 346                                        <div class="col text-muted" style="font-size:0.75rem;">Заказы Yandex Market за п
 347                                    </form>
 348                                </div>
 349
 350                                <div class="job-platform-row">
 351                                    <form asp-area="Studio2" asp-controller="Job" method="post" asp-action="SetChatSyncR
 352                                          class="row g-2 align-items-center">
 353                                        <div class="col-auto">
 354                                            <span class="job-platform-badge job-platform-badge--chats">ЧАТЫ</span>
 355                                        </div>
 356                                        <div class="col-auto">
 357                                            <div class="job-inline">
 358                                                <input type="number" class="form-control form-control-sm input-number" n
 359                                                       min="1" max="60" value="30"/>
 360                                                <span class="job-inline-label">мин.</span>
 361                                            </div>
 362                                        </div>
 363                                        <div class="col-auto">
 364                                            <button type="submit" class="btn btn-sm job-btn-chats">
 365                                                <i class="fas fa-save me-1"></i>Сохранить
 366                                            </button>
 367                                        </div>
 368                                        <div class="col text-muted" style="font-size:0.75rem;">Связывает открытые чаты O
 369                                    </form>
 370                                </div>
 371
 372                            </div>
 373                        </div>
 374                    </section>
 375
 376                </div>
 377            </div>
 378        </div>
 379
 380        <!-- Вкладка: Очередь Hangfire -->
 381        <div class="tab-pane fade @(Model.ActivePageTab == "queue" ? "show active" : "")"
 382             id="tab-queue" role="tabpanel">
 383            <div class="job-card-body">
 384                <p class="job-section-lead mb-3">
 385                    Снимок очереди Hangfire для выбранной queue. Вкладки enqueued/processing/succeeded — полный reload с
 386                    Для отмены, retry и детального stack trace используйте Hangfire Dashboard.
 387                </p>
 0388                @if (hq == null)
 389                {
 390                    <div class="alert alert-warning mb-0">
 391                        <i class="fas fa-exclamation-triangle me-1"></i>Не удалось загрузить данные очереди Hangfire.
 392                    </div>
 393                }
 394                else
 395                {
 396                    <div class="row g-2 align-items-end mb-3">
 397                        <div class="col-sm-4 col-md-3">
 398                            <label class="form-label small fw-semibold text-secondary">Очередь</label>
 399                            <select class="form-select form-select-sm custom-select-init" id="hqQueueSelect">
 0400                                @foreach (var q in hq.AvailableQueues ?? new List<string>())
 401                                {
 0402                                    <option value="@q" selected="@(q == hq.QueueName)">@q</option>
 403                                }
 404                            </select>
 405                        </div>
 406                        <div class="col-auto">
 407                            <a class="btn btn-sm btn-outline-secondary"
 408                               href="@Url.Action("Index", "Job", new { area = "Studio2", pageTab = "queue", queueName = 
 409                                <i class="fas fa-rotate me-1"></i>Обновить
 410                            </a>
 411                        </div>
 412                        <div class="col text-muted small">
 413                            Переключение вкладок ниже перезагружает страницу для обновления данных.
 414                        </div>
 415                    </div>
 416
 417                    <ul class="nav nav-tabs mb-3">
 418                        <li class="nav-item">
 419                            <a class="nav-link @(hq.ActiveTab == "enqueued" ? "active" : "")"
 420                               href="@Url.Action("Index", "Job", new { area = "Studio2", pageTab = "queue", queueName = 
 421                                <i class="fas fa-clock me-1"></i>В очереди
 0422                                <span class="badge bg-secondary ms-1">@hq.EnqueuedJobs?.Count</span>
 423                            </a>
 424                        </li>
 425                        <li class="nav-item">
 426                            <a class="nav-link @(hq.ActiveTab == "processing" ? "active" : "")"
 427                               href="@Url.Action("Index", "Job", new { area = "Studio2", pageTab = "queue", queueName = 
 428                                <i class="fas fa-sync-alt me-1"></i>Выполняются
 0429                                <span class="badge bg-secondary ms-1">@hq.ProcessingJobs?.Count</span>
 430                            </a>
 431                        </li>
 432                        <li class="nav-item">
 433                            <a class="nav-link @(hq.ActiveTab == "succeeded" ? "active" : "")"
 434                               href="@Url.Action("Index", "Job", new { area = "Studio2", pageTab = "queue", queueName = 
 435                                <i class="fas fa-check-circle me-1"></i>Выполненные
 0436                                <span class="badge bg-secondary ms-1">@hq.SucceededJobs?.Count</span>
 437                            </a>
 438                        </li>
 439                        <li class="nav-item">
 440                            <a class="nav-link @(hq.ActiveTab == "deleted" ? "active" : "")"
 441                               href="@Url.Action("Index", "Job", new { area = "Studio2", pageTab = "queue", queueName = 
 442                                <i class="fas fa-trash me-1"></i>Удалённые
 0443                                <span class="badge bg-secondary ms-1">@hq.DeletedJobs?.Count</span>
 444                            </a>
 445                        </li>
 446                        <li class="nav-item">
 447                            <a class="nav-link @(hq.ActiveTab == "statistics" ? "active" : "")"
 448                               href="@Url.Action("Index", "Job", new { area = "Studio2", pageTab = "queue", queueName = 
 449                                <i class="fas fa-chart-bar me-1"></i>Статистика
 450                            </a>
 451                        </li>
 452                    </ul>
 453
 454                    <div>
 0455                        @if (hq.ActiveTab == "enqueued")
 456                        {
 0457                            @await Html.PartialAsync("_HangfireJobsGrid", ("enqueued", hq.EnqueuedJobs))
 458                        }
 0459                        else if (hq.ActiveTab == "processing")
 460                        {
 0461                            @await Html.PartialAsync("_HangfireJobsGrid", ("processing", hq.ProcessingJobs))
 462                        }
 0463                        else if (hq.ActiveTab == "succeeded")
 464                        {
 0465                            @await Html.PartialAsync("_HangfireJobsGrid", ("succeeded", hq.SucceededJobs))
 466                        }
 0467                        else if (hq.ActiveTab == "deleted")
 468                        {
 0469                            @await Html.PartialAsync("_HangfireJobsGrid", ("deleted", hq.DeletedJobs))
 470                        }
 0471                        else if (hq.ActiveTab == "statistics")
 472                        {
 473                            <partial name="_StatisticsPartial" model="hq.SucceededJobs"/>
 474                        }
 475                    </div>
 476
 477                    <script>
 478                        (function () {
 479                            const sel = document.getElementById('hqQueueSelect');
 480                            if (!sel) return;
 481                            sel.addEventListener('change', function () {
 0482                                const base = '@Url.Action("Index", "Job", new { area = "Studio2" })';
 0483                                const url = base + '?pageTab=queue&queueName=' + encodeURIComponent(sel.value) + '&queue
 484                                window.location.href = url;
 485                            });
 486                        })();
 487                    </script>
 488                }
 489            </div>
 490        </div>
 491
 492        <!-- Вкладка: Результаты -->
 493        <div class="tab-pane fade @(Model.ActivePageTab == "results" ? "show active" : "")"
 494             id="tab-results" role="tabpanel">
 495            <div class="job-card-body">
 496                <p class="job-section-lead mb-3">
 497                    Текстовые результаты последних 50 succeeded-задач из Hangfire (краткий лог после выполнения).
 498                    Полная история с параметрами — во вкладке «Выполненные» очереди или в <a href="/hangfire" target="_b
 499                </p>
 0500                @if (Model.Results.Any())
 501                {
 502                    <ul class="job-results-list">
 0503                        @foreach (var result in Model.Results)
 504                        {
 0505                            <li>@Html.Raw(result)</li>
 506                        }
 507                    </ul>
 508                }
 509                else
 510                {
 511                    <div class="text-center text-muted py-4">
 512                        <i class="fas fa-inbox fa-2x mb-2 d-block text-secondary opacity-50"></i>
 513                        <div class="small">Нет завершённых задач с результатами</div>
 514                    </div>
 515                }
 516            </div>
 517        </div>
 518
 519    </div>
 520</div>
 521
 522<!-- Модальное окно: прогресс обновления статусов -->
 523<div class="modal fade" id="statusUpdateModal" tabindex="-1" aria-labelledby="statusUpdateModalLabel"
 524     aria-hidden="true" data-bs-backdrop="static">
 525    <div class="modal-dialog modal-dialog-centered modal-xl">
 526        <div class="modal-content">
 527            <div class="modal-header py-2">
 528                <h5 class="modal-title d-flex align-items-center gap-2" id="statusUpdateModalLabel">
 529                    <i class="fas fa-sync-alt fa-spin" id="statusUpdateModalSpinner"></i>
 530                    <span>Обновление статусов заказов</span>
 531                </h5>
 532                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"
 533                        id="statusUpdateModalCloseBtn" style="display: none;"></button>
 534            </div>
 535            <div class="modal-body">
 536                <div class="progress-status">
 537                    <span id="statusUpdateProgressText">Подключение...</span>
 538                    <span class="text-muted small">Прошло: <span id="statusUpdateElapsed">00:00</span></span>
 539                    <span class="text-muted small">Найдено: <span id="statusUpdateMatches">0</span></span>
 540                </div>
 541                <div class="progress-bar-container mb-2">
 542                    <div class="progress h-100" role="progressbar" style="min-width: 2%;">
 543                        <div class="progress-bar progress-bar-striped progress-bar-animated"
 544                             id="statusUpdateProgressBar" style="width: 0%;">0%</div>
 545                    </div>
 546                </div>
 547                <div class="input-group input-group-sm mb-2">
 548                    <span class="input-group-text"><i class="fas fa-search"></i></span>
 549                    <input type="search" class="form-control" id="statusUpdateLogSearch" placeholder="Поиск по логам..."
 550                    <button type="button" class="btn btn-outline-secondary" id="statusUpdateClearLogBtn">Очистить</butto
 551                </div>
 552                <div class="log-console" id="statusUpdateLogConsole"></div>
 553                <div class="result-summary d-none" id="statusUpdateResultSummary"></div>
 554            </div>
 555            <div class="modal-footer py-2">
 556                <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal"
 557                        id="statusUpdateModalCloseFooterBtn" style="display: none;">Закрыть</button>
 558            </div>
 559        </div>
 560    </div>
 561</div>
 562
 563@section Scripts {
 564<script src="~/js/Studio2/Grid/bsx-directory.js" asp-append-version="true"></script>
 565<script src="~/js/Studio2/HangfireQueue/hangfire-jobs-grid.js" asp-append-version="true"></script>
 566<script src="~/js/Studio2/Job/job-schedules-grid.js" asp-append-version="true"></script>
 567<script>
 568    document.addEventListener('DOMContentLoaded', function () {
 569        // Валидация числовых полей
 570        document.querySelectorAll('input[type="number"]').forEach(input => {
 571            input.addEventListener('change', function () {
 572                const min = parseInt(this.min), max = parseInt(this.max), val = parseInt(this.value);
 573                if (val < min) this.value = min;
 574                if (val > max) this.value = max;
 575            });
 576        });
 577
 578        // Ведущий ноль для полей времени
 579        ['updateHour', 'updateMinute'].forEach(id => {
 580            const el = document.getElementById(id);
 581            if (el) el.addEventListener('blur', function () {
 582                if (this.value.length === 1) this.value = '0' + this.value;
 583            });
 584        });
 585
 586        // --- Обновление статусов по периоду: SignalR ---
 587        const formStatusUpdate = document.getElementById('formStatusUpdateByPeriod');
 0588        const startStatusUpdateUrl = '@Url.Action("StartStatusUpdateByPeriod", "Job", new { area = "Studio2" })';
 589        const statusUpdateModalEl = document.getElementById('statusUpdateModal');
 590
 591        let statusUpdateConnection = null;
 592        let statusUpdateElapsedTimer = null;
 593        let statusUpdateStartedAtMs = 0;
 594        let statusUpdateSearchQuery = '';
 595
 596        function fmt2(n) { return String(n).padStart(2, '0'); }
 597        function formatElapsed(ms) {
 598            const s = Math.max(0, Math.floor(ms / 1000));
 599            return fmt2(Math.floor(s / 60)) + ':' + fmt2(s % 60);
 600        }
 601        function startElapsed() {
 602            stopElapsed();
 603            statusUpdateStartedAtMs = Date.now();
 604            const el = document.getElementById('statusUpdateElapsed');
 605            if (el) el.textContent = '00:00';
 606            statusUpdateElapsedTimer = setInterval(() => {
 607                const e = document.getElementById('statusUpdateElapsed');
 608                if (e) e.textContent = formatElapsed(Date.now() - statusUpdateStartedAtMs);
 609            }, 1000);
 610        }
 611        function stopElapsed() {
 612            if (statusUpdateElapsedTimer) { clearInterval(statusUpdateElapsedTimer); statusUpdateElapsedTimer = null; }
 613        }
 614        function updateMatches() {
 615            const lc = document.getElementById('statusUpdateLogConsole');
 616            const me = document.getElementById('statusUpdateMatches');
 617            if (lc && me) me.textContent = String(lc.querySelectorAll('.log-line:not(.d-none)').length);
 618        }
 619        function applySearchFilter() {
 620            const lc = document.getElementById('statusUpdateLogConsole');
 621            if (!lc) return;
 622            const q = (statusUpdateSearchQuery || '').trim().toLowerCase();
 623            lc.querySelectorAll('.log-line').forEach(line => {
 624                line.classList.toggle('d-none', q.length > 0 && !(line.dataset.textLower || '').includes(q));
 625            });
 626            updateMatches();
 627        }
 628        function appendLog(msg) {
 629            const lc = document.getElementById('statusUpdateLogConsole');
 630            if (!lc) return;
 631            const line = document.createElement('div');
 632            line.className = 'log-line';
 633            line.textContent = msg;
 634            line.dataset.textLower = (msg || '').toLowerCase();
 635            if (statusUpdateSearchQuery) {
 636                const q = statusUpdateSearchQuery.trim().toLowerCase();
 637                if (q.length > 0 && !line.dataset.textLower.includes(q)) line.classList.add('d-none');
 638            }
 639            lc.appendChild(line);
 640            updateMatches();
 641            lc.scrollTop = lc.scrollHeight;
 642        }
 643        function setProgress(current, total, statusText) {
 644            const pb = document.getElementById('statusUpdateProgressBar');
 645            const pt = document.getElementById('statusUpdateProgressText');
 646            if (!pb || !pt) return;
 647            const pct = total > 0 ? Math.round((current / total) * 100) : 0;
 648            pb.style.width = pct + '%';
 649            pb.textContent = pct + '%';
 650            pt.textContent = statusText || (total > 0 ? ('Обработано ' + current + ' из ' + total) : '');
 651        }
 652        function resetModalUI() {
 653            const lc = document.getElementById('statusUpdateLogConsole');
 654            const pb = document.getElementById('statusUpdateProgressBar');
 655            const pt = document.getElementById('statusUpdateProgressText');
 656            const rs = document.getElementById('statusUpdateResultSummary');
 657            const sp = document.getElementById('statusUpdateModalSpinner');
 658            const cb = document.getElementById('statusUpdateModalCloseBtn');
 659            const cf = document.getElementById('statusUpdateModalCloseFooterBtn');
 660            const si = document.getElementById('statusUpdateLogSearch');
 661            const me = document.getElementById('statusUpdateMatches');
 662            if (lc) lc.innerHTML = '';
 663            if (pb) { pb.style.width = '0%'; pb.textContent = '0%'; }
 664            if (pt) pt.textContent = 'Подключение...';
 665            if (rs) { rs.classList.add('d-none'); rs.classList.remove('error'); rs.innerHTML = ''; }
 666            if (cb) cb.style.display = 'none';
 667            if (cf) cf.style.display = 'none';
 668            if (sp) sp.classList.add('fa-spin');
 669            statusUpdateSearchQuery = '';
 670            if (si) si.value = '';
 671            if (me) me.textContent = '0';
 672            startElapsed();
 673        }
 674        function finishModal(success, message) {
 675            const pb = document.getElementById('statusUpdateProgressBar');
 676            const pt = document.getElementById('statusUpdateProgressText');
 677            const rs = document.getElementById('statusUpdateResultSummary');
 678            const sp = document.getElementById('statusUpdateModalSpinner');
 679            const cb = document.getElementById('statusUpdateModalCloseBtn');
 680            const cf = document.getElementById('statusUpdateModalCloseFooterBtn');
 681            if (sp) sp.classList.remove('fa-spin');
 682            if (pb) { pb.style.width = '100%'; pb.textContent = '100%'; }
 683            if (pt) pt.textContent = success ? 'Готово' : 'Ошибка';
 684            if (message && rs) {
 685                rs.classList.remove('d-none');
 686                rs.classList.toggle('error', !success);
 687                rs.textContent = message;
 688            }
 689            if (cb) cb.style.display = 'block';
 690            if (cf) cf.style.display = 'block';
 691            stopElapsed();
 692        }
 693        function finishModalInfo(message) {
 694            const pb = document.getElementById('statusUpdateProgressBar');
 695            const pt = document.getElementById('statusUpdateProgressText');
 696            const rs = document.getElementById('statusUpdateResultSummary');
 697            const sp = document.getElementById('statusUpdateModalSpinner');
 698            const cb = document.getElementById('statusUpdateModalCloseBtn');
 699            const cf = document.getElementById('statusUpdateModalCloseFooterBtn');
 700            if (sp) sp.classList.remove('fa-spin');
 701            if (pb) { pb.style.width = '100%'; pb.textContent = '...'; }
 702            if (pt) pt.textContent = 'Выполняется';
 703            if (message && rs) { rs.classList.remove('d-none'); rs.classList.remove('error'); rs.textContent = message; 
 704            if (cb) cb.style.display = 'block';
 705            if (cf) cf.style.display = 'block';
 706            stopElapsed();
 707        }
 708
 709        if (statusUpdateModalEl) {
 710            statusUpdateModalEl.addEventListener('input', function (e) {
 711                if (e.target && e.target.id === 'statusUpdateLogSearch') {
 712                    statusUpdateSearchQuery = e.target.value || '';
 713                    applySearchFilter();
 714                }
 715            });
 716            const clearBtn = document.getElementById('statusUpdateClearLogBtn');
 717            if (clearBtn) clearBtn.addEventListener('click', function () {
 718                const lc = document.getElementById('statusUpdateLogConsole');
 719                if (lc) lc.innerHTML = '';
 720                updateMatches();
 721            });
 722            statusUpdateModalEl.addEventListener('hidden.bs.modal', async function () {
 723                stopElapsed();
 724                if (statusUpdateConnection) {
 725                    try { await statusUpdateConnection.stop(); } catch { /* ignore */ }
 726                    statusUpdateConnection = null;
 727                }
 728            });
 729        }
 730
 731        if (formStatusUpdate && statusUpdateModalEl) {
 732            formStatusUpdate.addEventListener('submit', async function (e) {
 733                e.preventDefault();
 734                const fromInput = document.getElementById('statusFrom');
 735                const toInput = document.getElementById('statusTo');
 736                if (!fromInput?.value || !toInput?.value) { formStatusUpdate.reportValidity(); return; }
 737
 738                const jobId = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : 'job-' + Date.n
 739                const modal = new bootstrap.Modal(statusUpdateModalEl);
 740                resetModalUI();
 741                modal.show();
 742
 743                if (statusUpdateConnection) {
 744                    try { await statusUpdateConnection.stop(); } catch { /* ignore */ }
 745                    statusUpdateConnection = null;
 746                }
 747
 748                try {
 749                    if (typeof signalR === 'undefined') {
 750                        appendLog('Ошибка: SignalR не загружен.');
 751                        finishModal(false, 'SignalR не загружен. Обновите страницу.');
 752                        return;
 753                    }
 754                    statusUpdateConnection = new signalR.HubConnectionBuilder()
 755                        .withUrl('/jobProgressHub')
 756                        .withAutomaticReconnect()
 757                        .build();
 758                    statusUpdateConnection.on('Log', (msg) => appendLog(msg));
 759                    statusUpdateConnection.on('Progress', (cur, tot, txt) => setProgress(cur, tot, txt || ''));
 760                    statusUpdateConnection.on('Complete', (ok, report) => {
 761                        finishModal(!!ok, report ? report.replace(/<[^>]+>/g, ' ').trim() : (ok ? 'Обновление завершено.
 762                    });
 763                    statusUpdateConnection.on('Error', (msg) => { appendLog('Ошибка: ' + msg); finishModal(false, msg ||
 764                    await statusUpdateConnection.start();
 765                    await statusUpdateConnection.invoke('JoinJobGroup', jobId);
 766                    setProgress(0, 0, 'Запуск задачи...');
 767                } catch (err) {
 768                    appendLog('Ошибка подключения SignalR: ' + (err?.message || err));
 769                    finishModal(false, 'Не удалось подключиться к серверу прогресса.');
 770                    return;
 771                }
 772
 773                try {
 774                    const fd = new FormData();
 775                    fd.append('from', fromInput.value);
 776                    fd.append('to', toInput.value);
 777                    fd.append('jobId', jobId);
 778                    const token = formStatusUpdate.querySelector('input[name="__RequestVerificationToken"]');
 779                    if (token) fd.append('__RequestVerificationToken', token.value);
 780
 781                    const resp = await fetch(startStatusUpdateUrl, { method: 'POST', body: fd });
 782                    if (resp.status === 409) {
 783                        let payload = null;
 784                        try { payload = await resp.json(); } catch { /* ignore */ }
 785                        const msg = payload?.message || 'Обновление уже выполняется или стоит в очереди.';
 786                        appendLog(msg);
 787                        if (statusUpdateConnection) {
 788                            try { await statusUpdateConnection.stop(); } catch { /* ignore */ }
 789                            statusUpdateConnection = null;
 790                        }
 791                        finishModalInfo(msg);
 792                        return;
 793                    }
 794                    if (!resp.ok) {
 795                        const errText = await resp.text();
 796                        appendLog('Ошибка запуска: ' + resp.status + ' ' + errText);
 797                        finishModal(false, 'Не удалось запустить задачу.');
 798                    }
 799                } catch (err) {
 800                    appendLog('Ошибка запроса: ' + (err?.message || err));
 801                    finishModal(false, err?.message || 'Ошибка сети.');
 802                }
 803            });
 804        }
 805    });
 806</script>
 807}