Реалізація прогрес-бару для довгих фонових завдань на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація прогрес-бару для довгих фонових завдань на сайті
Середня
~2-3 робочих дні
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація прогресс-бара для довгих фонових задач на сайті

Коли користувач запускає задачу, яка займає більше 2–3 секунд, інтерфейс повинен показувати прогрес. Інакше — повторні клікання, закриття сторінки, звонки в підтримку. Задача технічно нетривіальна: прогрес формується на сервері, потрібно передавати його в браузер без polling кожні 500ms.

Архітектура

Схема працює так: клієнт запускає задачу → отримує job_id → підписується на оновлення через SSE або WebSocket → бекенд обробляє задачу в черзі → воркер періодично публікує прогрес у Redis → SSE/WebSocket сервер доставляє оновлення клієнту.

Polling (запити кожні N секунд) працює, але створює непотрібне навантаження і дергаючий UX. SSE — правильний вибір для односпрямованого потоку прогресу.

Бекенд: Laravel + Redis

// app/Jobs/ProcessImportJob.php
class ProcessImportJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        private readonly string $jobId,
        private readonly int $userId,
        private readonly string $filePath
    ) {}

    public function handle(): void
    {
        $rows = $this->parseFile($this->filePath);
        $total = count($rows);

        foreach ($rows as $index => $row) {
            $this->processRow($row);

            // Публікуємо прогрес кожні 10 записів
            if ($index % 10 === 0 || $index === $total - 1) {
                $progress = (int)(($index + 1) / $total * 100);
                $this->publishProgress($progress, $index + 1, $total);
            }
        }

        $this->publishProgress(100, $total, $total, 'completed');
    }

    private function publishProgress(
        int $percent,
        int $processed,
        int $total,
        string $status = 'running'
    ): void
    {
        $payload = json_encode([
            'jobId'     => $this->jobId,
            'percent'   => $percent,
            'processed' => $processed,
            'total'     => $total,
            'status'    => $status,
            'ts'        => microtime(true),
        ]);

        // Публікуємо в Redis Pub/Sub
        Redis::publish("job-progress:{$this->userId}", $payload);

        // Зберігаємо останній стан для переподключень
        Redis::setex("job-progress-state:{$this->jobId}", 3600, $payload);
    }
}
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/jobs/import', function (Request $request) {
        $jobId = Str::uuid()->toString();
        ProcessImportJob::dispatch($jobId, Auth::id(), $request->file('csv')->store('imports'));
        return response()->json(['jobId' => $jobId]);
    });

    Route::get('/jobs/{jobId}/progress-stream', function (string $jobId) {
        // Перевіряємо приналежність задачі користувачу
        abort_unless(JobOwnership::check($jobId, Auth::id()), 403);

        return response()->stream(function () use ($jobId) {
            // Повертаємо останній відомий стан одразу
            $lastState = Redis::get("job-progress-state:{$jobId}");
            if ($lastState) {
                echo "data: {$lastState}\n\n";
                ob_flush();
                flush();
            }

            $redis = new \Redis();
            $redis->connect(config('database.redis.default.host'));
            $redis->subscribe(["job-progress:" . Auth::id()], function ($redis, $channel, $message) use ($jobId) {
                $data = json_decode($message, true);
                if ($data['jobId'] !== $jobId) return; // фільтр за jobId

                echo "data: {$message}\n\n";
                ob_flush();
                flush();

                if ($data['status'] === 'completed' || $data['status'] === 'failed') {
                    $redis->unsubscribe();
                }
            });
        }, 200, [
            'Content-Type'      => 'text/event-stream',
            'Cache-Control'     => 'no-cache',
            'X-Accel-Buffering' => 'no', // відключає буферизацію Nginx
            'Connection'        => 'keep-alive',
        ]);
    });
});

Фронтенд: React компонент

import { useState, useEffect, useRef } from 'react';

interface JobProgress {
  jobId: string;
  percent: number;
  processed: number;
  total: number;
  status: 'running' | 'completed' | 'failed';
}

function useJobProgress(jobId: string | null) {
  const [progress, setProgress] = useState<JobProgress | null>(null);
  const eventSourceRef = useRef<EventSource | null>(null);

  useEffect(() => {
    if (!jobId) return;

    const es = new EventSource(`/api/jobs/${jobId}/progress-stream`, {
      withCredentials: true,
    });

    es.onmessage = (event) => {
      const data: JobProgress = JSON.parse(event.data);
      setProgress(data);

      if (data.status === 'completed' || data.status === 'failed') {
        es.close();
      }
    };

    es.onerror = () => {
      // SSE автоматично переподключається при помилці
      // але при 403/404 — ні, тому перевіряємо статус
      es.close();
    };

    eventSourceRef.current = es;
    return () => es.close();
  }, [jobId]);

  return progress;
}

interface ProgressBarProps {
  percent: number;
  status: JobProgress['status'];
  processed: number;
  total: number;
}

function ProgressBar({ percent, status, processed, total }: ProgressBarProps) {
  return (
    <div className="w-full">
      <div className="flex justify-between text-sm text-gray-600 mb-1">
        <span>
          {status === 'completed' ? 'Готово' : `Оброблено ${processed} з ${total}`}
        </span>
        <span>{percent}%</span>
      </div>
      <div className="w-full bg-gray-200 rounded-full h-2.5">
        <div
          className={`h-2.5 rounded-full transition-all duration-300 ${
            status === 'failed' ? 'bg-red-500' :
            status === 'completed' ? 'bg-green-500' :
            'bg-blue-600'
          }`}
          style={{ width: `${percent}%` }}
        />
      </div>
    </div>
  );
}

export function ImportWithProgress() {
  const [jobId, setJobId] = useState<string | null>(null);
  const progress = useJobProgress(jobId);

  async function handleFileUpload(file: File) {
    const formData = new FormData();
    formData.append('csv', file);

    const res = await fetch('/api/jobs/import', {
      method: 'POST',
      body: formData,
    });
    const { jobId } = await res.json();
    setJobId(jobId);
  }

  return (
    <div>
      {!jobId && (
        <input
          type="file"
          accept=".csv"
          onChange={(e) => e.target.files?.[0] && handleFileUpload(e.target.files[0])}
        />
      )}
      {progress && (
        <ProgressBar
          percent={progress.percent}
          status={progress.status}
          processed={progress.processed}
          total={progress.total}
        />
      )}
      {progress?.status === 'completed' && (
        <p className="text-green-600 mt-2">Імпорт завершився успішно</p>
      )}
    </div>
  );
}

Конфігурація Nginx

SSE вимагає відключення буферизації на рівні проксі:

location /api/jobs/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 3600s;  // довгі задачі
    chunked_transfer_encoding on;
}

Обробка падіння воркера

Якщо воркер упав у середині задачі, прогрес зависне. Потрібен таймаут:

// У SSE-контролері після підписки
$timeout = 0;
$maxWait = 300; // 5 хвилин

// Періодично перевіряємо, чи не умерла задача
while ($timeout < $maxWait) {
    sleep(5);
    $timeout += 5;
    $state = Redis::get("job-progress-state:{$jobId}");
    if (!$state) {
        echo "data: " . json_encode(['status' => 'failed', 'error' => 'timeout']) . "\n\n";
        flush();
        break;
    }
}

Терміни

Прогресс-бар для одного типу задачі з SSE — 1–2 дні. Універсальна система з кількома типами задач, обробкою помилок, історією задач користувача та моніторингом зависших джобів — 4–5 днів.