copier: прогресс по байтам, скорость и ETA
- Прогрессбар по скопированным байтам (doneBytes / totalBytes) - SpeedBPS и ETASec добавлены в Task - Dashboard показывает скорость (МБ/с) и ETA справа от прогрессбара Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"jukebox_maker/internal/config"
|
"jukebox_maker/internal/config"
|
||||||
"jukebox_maker/internal/db"
|
"jukebox_maker/internal/db"
|
||||||
@@ -150,36 +151,63 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// суммарный объём для прогресса (всех файлов в списке)
|
||||||
|
var totalBytes int64
|
||||||
|
for _, f := range files {
|
||||||
|
totalBytes += f.size
|
||||||
|
}
|
||||||
|
|
||||||
total := len(files)
|
total := len(files)
|
||||||
copied := 0
|
copied := 0
|
||||||
|
var doneBytes int64
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
for i, f := range files {
|
for i, f := range files {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
c.tasks.Update(taskID, func(t *task.Task) {
|
c.tasks.Update(taskID, func(t *task.Task) {
|
||||||
t.Status = task.StatusCanceled
|
t.Status = task.StatusCanceled
|
||||||
t.Message = "Отменено"
|
t.Message = "Отменено"
|
||||||
|
t.SpeedBPS = 0
|
||||||
|
t.ETASec = 0
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.size > available {
|
if f.size > available {
|
||||||
// файл не влезает — пробуем следующий
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
prog := int(float64(i+1) / float64(total) * 100)
|
elapsed := time.Since(startTime).Seconds()
|
||||||
|
var speedBPS, etaSec int64
|
||||||
|
if elapsed > 0 && doneBytes > 0 {
|
||||||
|
speedBPS = int64(float64(doneBytes) / elapsed)
|
||||||
|
remaining := totalBytes - doneBytes
|
||||||
|
if speedBPS > 0 {
|
||||||
|
etaSec = remaining / speedBPS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prog := int(float64(doneBytes) / float64(totalBytes) * 100)
|
||||||
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
|
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
|
||||||
setStatus(task.StatusRunning, msg, prog)
|
|
||||||
|
|
||||||
// destination mirrors source structure under destRoot
|
c.tasks.Update(taskID, func(t *task.Task) {
|
||||||
|
t.Status = task.StatusRunning
|
||||||
|
t.Message = msg
|
||||||
|
t.Progress = prog
|
||||||
|
t.SpeedBPS = speedBPS
|
||||||
|
t.ETASec = int(etaSec)
|
||||||
|
})
|
||||||
|
|
||||||
dstAbs := filepath.Join(destRoot, f.relPath)
|
dstAbs := filepath.Join(destRoot, f.relPath)
|
||||||
|
|
||||||
if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil {
|
if err := rsyncFile(ctx, f.srcAbs, dstAbs); err != nil {
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
c.tasks.Update(taskID, func(t *task.Task) {
|
c.tasks.Update(taskID, func(t *task.Task) {
|
||||||
t.Status = task.StatusCanceled
|
t.Status = task.StatusCanceled
|
||||||
t.Message = "Отменено"
|
t.Message = "Отменено"
|
||||||
|
t.SpeedBPS = 0
|
||||||
|
t.ETASec = 0
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -187,6 +215,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
|||||||
}
|
}
|
||||||
|
|
||||||
available -= f.size
|
available -= f.size
|
||||||
|
doneBytes += f.size
|
||||||
copied++
|
copied++
|
||||||
_ = database.RecordCopy(db.CopyRecord{
|
_ = database.RecordCopy(db.CopyRecord{
|
||||||
DiskID: opts.DiskID,
|
DiskID: opts.DiskID,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Task struct {
|
|||||||
Status Status `json:"status"`
|
Status Status `json:"status"`
|
||||||
Progress int `json:"progress"`
|
Progress int `json:"progress"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
SpeedBPS int64 `json:"speed_bps"`
|
||||||
|
ETASec int `json:"eta_sec"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -30,7 +30,10 @@
|
|||||||
<div class="progress-bar-bg">
|
<div class="progress-bar-bg">
|
||||||
<div class="progress-bar-fill" id="progressFill" style="width:0%"></div>
|
<div class="progress-bar-fill" id="progressFill" style="width:0%"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-label" id="progressMsg">Подготовка…</div>
|
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-top:6px">
|
||||||
|
<div class="progress-label" id="progressMsg">Подготовка…</div>
|
||||||
|
<div class="progress-label" id="progressMeta" style="text-align:right"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -83,6 +86,21 @@ async function refreshDisk() {
|
|||||||
function startTaskPoll(id) { stopTaskPoll(); pollInterval = setInterval(() => pollTask(id), 1500); }
|
function startTaskPoll(id) { stopTaskPoll(); pollInterval = setInterval(() => pollTask(id), 1500); }
|
||||||
function stopTaskPoll() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }
|
function stopTaskPoll() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }
|
||||||
|
|
||||||
|
function fmtSpeed(bps) {
|
||||||
|
if (!bps) return '';
|
||||||
|
if (bps >= 1e9) return (bps/1e9).toFixed(1) + ' ГБ/с';
|
||||||
|
if (bps >= 1e6) return (bps/1e6).toFixed(1) + ' МБ/с';
|
||||||
|
if (bps >= 1e3) return (bps/1e3).toFixed(0) + ' КБ/с';
|
||||||
|
return bps + ' Б/с';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtETA(sec) {
|
||||||
|
if (!sec || sec <= 0) return '';
|
||||||
|
if (sec >= 3600) return Math.floor(sec/3600) + ' ч ' + Math.floor((sec%3600)/60) + ' мин';
|
||||||
|
if (sec >= 60) return Math.floor(sec/60) + ' мин';
|
||||||
|
return sec + ' с';
|
||||||
|
}
|
||||||
|
|
||||||
async function pollTask(id) {
|
async function pollTask(id) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/tasks/' + id);
|
const r = await fetch('/api/tasks/' + id);
|
||||||
@@ -90,12 +108,18 @@ async function pollTask(id) {
|
|||||||
const t = await r.json();
|
const t = await r.json();
|
||||||
document.getElementById('progressFill').style.width = t.progress + '%';
|
document.getElementById('progressFill').style.width = t.progress + '%';
|
||||||
document.getElementById('progressMsg').textContent = t.message || '…';
|
document.getElementById('progressMsg').textContent = t.message || '…';
|
||||||
|
|
||||||
|
const speed = fmtSpeed(t.speed_bps);
|
||||||
|
const eta = fmtETA(t.eta_sec);
|
||||||
|
const meta = [speed, eta ? 'ETA: ' + eta : ''].filter(Boolean).join(' · ');
|
||||||
|
document.getElementById('progressMeta').textContent = meta;
|
||||||
if (['success','failed','canceled'].includes(t.status)) {
|
if (['success','failed','canceled'].includes(t.status)) {
|
||||||
stopTaskPoll(); activeTaskId = null;
|
stopTaskPoll(); activeTaskId = null;
|
||||||
document.getElementById('btnStart').disabled = false;
|
document.getElementById('btnStart').disabled = false;
|
||||||
document.getElementById('btnStart').classList.remove('hidden');
|
document.getElementById('btnStart').classList.remove('hidden');
|
||||||
document.getElementById('btnCancel').classList.add('hidden');
|
document.getElementById('btnCancel').classList.add('hidden');
|
||||||
document.getElementById('progressPanel').classList.add('hidden');
|
document.getElementById('progressPanel').classList.add('hidden');
|
||||||
|
document.getElementById('progressMeta').textContent = '';
|
||||||
if (t.status === 'success') toast(t.message || 'Готово', 'ok');
|
if (t.status === 'success') toast(t.message || 'Готово', 'ok');
|
||||||
if (t.status === 'failed') toast('Ошибка: ' + t.error, 'error');
|
if (t.status === 'failed') toast('Ошибка: ' + t.error, 'error');
|
||||||
if (t.status === 'canceled') toast('Копирование отменено', 'error');
|
if (t.status === 'canceled') toast('Копирование отменено', 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user