Загрузка данных


      return { statusClass, statusStyle };
    }

    /**
     * Рендерит ссылку на отчёт запуска.
     */
    renderReportLink(id, innerHtml, { linkClass = "text-blue-600 hover:underline", ariaLabel = "" } = {}) {
      const safeId = this.escapeHtml(id ?? "-");
      const label = ariaLabel
        ? this.escapeHtml(ariaLabel)
        : `Открыть отчёт запуска #${safeId}`;
      return (
        `<a href="/reports/${safeId}" class="${linkClass}" target="_blank" rel="noopener noreferrer" ` +
          `aria-label="${label}">` +
          innerHtml +
        `</a>`
      );
    }

    /**
     * Рендерит текстовый статус для legacy-записей без status_stats.
     */
    renderLegacyStatusText(item) {
      const statusValue = String(item.status || "").toLowerCase();
      const status = this.escapeHtml(item.status || "-");
      const { statusClass, statusStyle } = this.getLegacyStatusPresentation(statusValue);
      return (
        `<span class="font-semibold ${statusClass}" style="${statusStyle}">${status}</span>`
      );
    }

    /**
     * Рендерит цветную шкалу статусов тестов.
     */
    renderStatusBar(stats, id) {
      const segments = [
        { key: TEST_STATUS.PASSED, count: stats.passed || 0, label: TEST_STATUS.PASSED },
        { key: TEST_STATUS.FAILED, count: stats.failed || 0, label: TEST_STATUS.FAILED },
        { key: TEST_STATUS.BROKEN, count: stats.broken || 0, label: TEST_STATUS.BROKEN },
        { key: TEST_STATUS.SKIPPED, count: stats.skipped || 0, label: TEST_STATUS.SKIPPED },
      ].filter((segment) => segment.count > 0);

      const totalCount = stats.total || segments.reduce((sum, segment) => sum + segment.count, 0);
      const labelClass = this.getStatusBarLabelClass(totalCount);
      const ariaLabel = segments
        .map((segment) => `${segment.label}: ${segment.count}`)
        .join(", ");

      const segmentHtml = segments
        .map((segment) => {
          const share = totalCount > 0 ? segment.count / totalCount : 0;
          const showLabel = share >= 0.08;
          const title = `${segment.label}: ${segment.count}`;
          const backgroundColor = STATUS_BAR_COLORS[segment.key] || STATUS_BAR_COLORS[TEST_STATUS.FAILED];
          const labelHtml = showLabel
            ? `<span class="${labelClass} leading-none">${this.escapeHtml(String(segment.count))}</span>`
            : "";
          return (
            `<div class="flex items-center justify-center min-w-0 text-white font-semibold" ` +
              `style="flex:${segment.count};background-color:${backgroundColor}" ` +
              `title="${this.escapeHtml(title)}">` +
              labelHtml +
            `</div>`
          );
        })
        .join("");

      const barHtml =
        `<div class="flex h-6 w-full rounded overflow-hidden" role="img" ` +
          `aria-label="${this.escapeHtml(ariaLabel)}">` +
          segmentHtml +
        `</div>`;

      return this.renderReportLink(id, barHtml, {
        linkClass: "block no-underline hover:opacity-90",
        ariaLabel: `Открыть отчёт запуска #${id}`,
      });
    }

    /**
     * Рендерит ячейку статуса: no_data, legacy-текст или шкала.
     */
    renderStatusCell(item) {
      const statusValue = String(item.status || "").toLowerCase();

      if (statusValue === TEST_STATUS.NO_DATA) {
        return `<span class="text-sm font-semibold text-gray-400">${TEST_STATUS.NO_DATA}</span>`;
      }

      if (!item.status_stats) {
        return this.renderLegacyStatusText(item);
      }

      if (item.status_stats.total === 0) {
        return `<span class="text-sm font-semibold text-gray-400">${TEST_STATUS.NO_DATA}</span>`;
      }

      return this.renderStatusBar(item.status_stats, item.id);
    }

    /**
     * Перерисовывает тело таблицы.
     * При пустом списке — показывает сообщение.
     *
     * @param {Array<Object>} items - Массив элементов отчётов.
     * @returns {void}
     */
    renderRows(items = []) {
      if (!this.tableBody) {
        return;
      }
  
      if (!items.length) {
        const emptyText = this.hasActiveFilters()
          ? "Ничего не найдено по выбранным фильтрам."
          : "Отчётов пока нет.";
  
        this.tableBody.innerHTML = `
          <tr>
            <td colspan="5" class="px-4 py-6 text-center text-sm text-gray-500">
              ${emptyText}
            </td>
          </tr>`;
        this.showMessage(emptyText, false);
        this.adjustTableHeight();
        return;
      }
  
      this.showMessage("", false);
      const rows = items
        .map((item) => {
          const id = this.escapeHtml(item.id ?? "-");
          const startDate = this.escapeHtml(this.formatLocalDate(item.start_date));
          const endDate = this.escapeHtml(this.formatLocalDate(item.end_date));
          const stand = this.escapeHtml(item.stand || "-");
          const statusCell = this.renderStatusCell(item);

          return (
            '<tr class="hover:bg-gray-50">' +
              `<td class="px-4 py-2 text-sm">` +
                `<a href="/reports/${id}" class="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">` +
                  `Запуск #${id}` +
                `</a>` +
              `</td>` +
              `<td class="px-4 py-2 text-sm text-gray-600">${startDate}</td>` +
              `<td class="px-4 py-2 text-sm text-gray-600">${endDate}</td>` +
              `<td class="px-4 py-2 text-sm text-gray-600">${stand}</td>` +
              `<td class="px-4 py-2 text-sm w-full text-center">${statusCell}</td>` +
            "</tr>"
          );
        })
        .join("");
  
      this.tableBody.innerHTML = rows;
      this.adjustTableHeight();
    }