Feature 315 Feedback

Progress Bar แถบความคืบหน้า

Determinate and indeterminate bars: a real upload with XHR progress events, a server job polled from SQL Server and a top page bar.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.

Upload with real progress

The file goes to POST /api/demo/upload. The server reads it and throws it away (nothing is stored).

No upload yet

Server job (progress stored in job_run)

A background thread reads every product and writes done_steps. The page asks for the row every 400 ms.

idle

-0%

#JobStatusStepsResult
No jobs yet.

Other styles

Indeterminate (unknown length)

Success / danger

Native <progress>

70%

What to notice

  • XMLHttpRequest.upload.onprogress gives real byte counts (fetch() cannot).
  • The background job writes done_steps to job_run; the page polls it every 400 ms.
  • <progress> and role=progressbar with aria-valuenow announce the value.

Try it

Source code

9 parts, read from the files in this project
src/main/java/com/study/uiux/web/DemoApi.java The upload endpoint reads the bytes and only reports the size
@PostMapping("/demo/upload")
public Map<String, Object> upload(@RequestParam("file") MultipartFile file) throws IOException {
    long bytes = 0;
    try (InputStream in = file.getInputStream()) {
        byte[] buffer = new byte[64 * 1024];
        for (int n; (n = in.read(buffer)) > 0; ) bytes += n;
    }
    return Map.of("name", String.valueOf(file.getOriginalFilename()), "bytes", bytes);
}
src/main/java/com/study/uiux/web/DemoApi.java GET /api/jobs/{id}: the page polls this
@GetMapping("/jobs/{id}")
public Map<String, Object> jobStatus(@PathVariable int id) {
    return job(jobs.get(id));
}
src/main/java/com/study/uiux/service/JobService.java Start a background job and return its id
public JobRun start() {
    boolean busy = jobs.findTop5ByOrderByIdDesc().stream().anyMatch(j -> "RUNNING".equals(j.getStatus())
            && j.getStartedAt().isAfter(Instant.now().minusSeconds(120)));
    if (busy) throw ApiException.badRequest("A job is already running. Wait until it finishes.");
    JobRun job = jobs.save(new JobRun("Recalculate stock value", (int) products.count()));
    worker.run(job.getId()); // @Async: returns immediately
    return job;
}
src/main/java/com/study/uiux/service/JobService.java The worker writes progress to job_run after every product
@Async
public void run(int jobId) {
    JobRun job = jobs.findById(jobId).orElseThrow();
    try {
        BigDecimal total = BigDecimal.ZERO;
        List<Product> all = products.findAll();
        for (int i = 0; i < all.size(); i++) {
            Product p = all.get(i);
            total = total.add(p.getPrice().multiply(BigDecimal.valueOf(p.getStock())));
            Thread.sleep(45); // pretend each product takes a while
            if (i % 4 == 3 || i == all.size() - 1) {
                job.setDoneSteps(i + 1);
                job.setMessage("Checked " + p.getName());
                job = jobs.save(job); // UPDATE job_run SET done_steps = ?
            }
        }
        job.setStatus("DONE");
        job.setMessage(String.format("Stock value: $%,.2f", total));
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        job.setStatus("FAILED");
        job.setMessage("Stopped");
    } catch (RuntimeException e) {
        job.setStatus("FAILED");
        job.setMessage(e.getMessage() == null ? "Failed" : e.getMessage());
    }
    job.setFinishedAt(Instant.now());
    jobs.save(job);
}
src/main/resources/db/migration/V1__schema.sql job_run: a background job writes its progress here, the page polls it
CREATE TABLE job_run (
    id          INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_job_run PRIMARY KEY,
    name        NVARCHAR(80)  NOT NULL,
    total_steps INT           NOT NULL,
    done_steps  INT           NOT NULL CONSTRAINT df_job_done DEFAULT 0,
    status      NVARCHAR(10)  NOT NULL CONSTRAINT df_job_status DEFAULT N'RUNNING',
    message     NVARCHAR(200) NOT NULL CONSTRAINT df_job_message DEFAULT N'',
    started_at  DATETIME2(0)  NOT NULL CONSTRAINT df_job_started DEFAULT SYSUTCDATETIME(),
    finished_at DATETIME2(0)  NULL,
    CONSTRAINT ck_job_status CHECK (status IN (N'RUNNING', N'DONE', N'FAILED'))
);
src/main/resources/static/css/app.css Progress bars: determinate, striped and indeterminate
.progress { height: .65rem; background: var(--surface-3); border-radius: 999px; overflow: hidden; position: relative; }
.progress-bar { height: 100%; width: 0; background: var(--primary); border-radius: inherit; transition: width .25s var(--ease); }
.progress.striped .progress-bar {
  background-image: linear-gradient(45deg, rgb(255 255 255 / .22) 25%, transparent 25%, transparent 50%, rgb(255 255 255 / .22) 50%, rgb(255 255 255 / .22) 75%, transparent 75%);
  background-size: 1rem 1rem; animation: stripes .8s linear infinite;
}
@keyframes stripes { to { background-position: 1rem 0; } }
.progress.indeterminate .progress-bar { width: 35%; position: absolute; animation: indeterminate 1.3s infinite var(--ease); }
@keyframes indeterminate { from { left: -35%; } to { left: 100%; } }
.progress.success .progress-bar { background: var(--success); }
.progress.danger .progress-bar { background: var(--danger); }
.top-progress { position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 300; pointer-events: none; }
.top-progress span { display: block; height: 100%; width: 0; background: var(--primary); box-shadow: 0 0 10px var(--primary); opacity: 0; transition: width .3s ease, opacity .3s; }
.top-progress.active span { opacity: 1; }
src/main/resources/static/js/core.js The thin bar at the top of the window follows every API call
const topProgress = (() => {
  const bar = $('#topProgress');
  const fill = bar && bar.firstElementChild;
  let active = 0, showTimer = null, trickle = null, width = 0;
  function set(w) { width = w; if (fill) fill.style.width = w + '%'; }
  return {
    start() {
      if (!bar) return;
      active++;
      if (active > 1) return;
      clearTimeout(showTimer);
      showTimer = setTimeout(() => {
        bar.classList.add('active'); set(8);
        trickle = setInterval(() => set(width + (90 - width) * 0.08), 200); // slows down near 90%
      }, 150);
    },
    done() {
      if (!bar || active === 0) return;
      active--;
      if (active > 0) return;
      clearTimeout(showTimer); clearInterval(trickle);
      if (!bar.classList.contains('active')) return;
      set(100);
      setTimeout(() => { bar.classList.remove('active'); setTimeout(() => set(0), 300); }, 250);
    }
  };
})();
src/main/resources/static/js/demos.js XMLHttpRequest gives upload progress events (fetch does not)
function upload(file) {
  if (file.size > 50 * 1024 * 1024) { toast({ type: 'error', title: 'Too large', message: 'The limit is 50 MB.' }); return; }
  const bar = $('#upBar'), xhr = new XMLHttpRequest(), form = new FormData(), t0 = performance.now();
  form.append('file', file);
  bar.classList.remove('success', 'danger'); bar.classList.add('striped');
  xhr.upload.onprogress = (e) => {
    if (!e.lengthComputable) return;
    const pct = (e.loaded / e.total) * 100, secs = (performance.now() - t0) / 1000;
    setBar(bar, pct);
    $('#upText').textContent = `${fmt(e.loaded)} of ${fmt(e.total)} (${pct.toFixed(0)}%)`;
    $('#upSpeed').textContent = secs > 0.2 ? fmt(e.loaded / secs) + '/s' : '';
  };
  xhr.onload = () => {
    bar.classList.remove('striped');
    if (xhr.status === 200) {
      const r = JSON.parse(xhr.responseText);
      bar.classList.add('success'); setBar(bar, 100);
      $('#upText').textContent = `Server received ${fmt(r.bytes)} of "${r.name}"`;
      toast({ type: 'success', title: 'Upload finished', message: r.name });
    } else { bar.classList.add('danger'); $('#upText').textContent = 'Upload failed (' + xhr.status + ')'; }
  };
  xhr.onerror = () => { bar.classList.add('danger'); $('#upText').textContent = 'Network error'; };
  xhr.open('POST', '/api/demo/upload');
  xhr.send(form);
}
src/main/resources/static/js/demos.js Poll the job row until it is DONE
async function follow(id) {
  const bar = $('#jobBar'), btn = $('#jobBtn');
  btn.disabled = true;
  for (;;) {
    const j = await api('/api/jobs/' + id, { silent: true });
    setBar(bar, j.percent);
    $('#jobPct').textContent = j.percent + '%';
    $('#jobText').textContent = `${j.done} / ${j.total} · ${j.message}`;
    $('#jobState').textContent = j.status.toLowerCase();
    if (j.status !== 'RUNNING') {
      btn.disabled = false;
      toast({ type: j.status === 'DONE' ? 'success' : 'error', title: 'Job ' + j.status.toLowerCase(), message: j.message });
      refreshJobs();
      return;
    }
    await new Promise((r) => setTimeout(r, 400));
  }
}

Are you sure?

Rename

↑↓ moveEnter openEsc close