Files
aliyunpan/internal/ui/dashboard_panel.go
T

834 lines
20 KiB
Go

// Copyright (c) 2020 tickstep.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"fmt"
"io"
"math"
"os"
"strings"
"sync"
"time"
"github.com/tickstep/aliyunpan/internal/utils"
"github.com/tickstep/library-go/converter"
"github.com/tickstep/library-go/requester/rio/speeds"
)
type TaskState int
type DashboardType int
const (
TaskQueued TaskState = iota
TaskRunning
TaskSuccess
TaskFailed
TaskSkipped
TaskCanceled
// DashboardPanelDownload 下载面板类型
DashboardPanelDownload DashboardType = 1
// DashboardPanelUpload 上传面板类型
DashboardPanelUpload DashboardType = 2
)
type DashboardOptions struct {
Title string
MaxHistory int
ActiveSlots int
Output io.Writer
}
// DashboardPanel 统计面板使用了很多ANSI转义序列。并不是所有的终端都支持ANSI转义序列,所以需要做兼容性处理。
type DashboardPanel struct {
dashboardType DashboardType
mu sync.Mutex
tasks map[string]*dashboardTask
order []string
history []string
maxHistory int
parallel int
activeSlots int
globalSpeeds *speeds.Speeds
title string
startTime time.Time
renderEvery time.Duration
stopCh chan struct{}
doneCh chan struct{}
startOnce sync.Once
closeOnce sync.Once
out io.Writer
}
type dashboardTask struct {
id string
path string
name string
total int64
downloaded int64
speed int64
eta time.Duration
state TaskState
message string
isFile bool
}
// NewDashboardPanel 创建下载任务统计面板
func NewDashboardPanel(dashboardType DashboardType, parallel int, globalSpeeds *speeds.Speeds, opts *DashboardOptions) *DashboardPanel {
if parallel < 1 {
parallel = 1
}
db := &DashboardPanel{
dashboardType: dashboardType,
tasks: make(map[string]*dashboardTask),
order: make([]string, 0, 128),
history: make([]string, 0, 64),
maxHistory: 8,
parallel: parallel,
activeSlots: 5,
globalSpeeds: globalSpeeds,
title: "aliyunpan统计面板",
renderEvery: 500 * time.Millisecond,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
out: os.Stdout,
}
if opts != nil {
if opts.Title != "" {
db.title = opts.Title
}
if opts.MaxHistory > 0 {
db.maxHistory = opts.MaxHistory
}
if opts.ActiveSlots > 0 {
db.activeSlots = opts.ActiveSlots
}
if opts.Output != nil {
db.out = opts.Output
}
}
if db.activeSlots < 1 {
db.activeSlots = 1
}
return db
}
// Start 启动面板
func (db *DashboardPanel) Start() {
if db == nil {
return
}
db.startOnce.Do(func() {
db.startTime = time.Now()
enableVirtualTerminalProcessing()
fmt.Fprint(db.out, "\x1b[?25l") // 隐藏光标
db.render()
ticker := time.NewTicker(db.renderEvery)
go func() {
defer close(db.doneCh)
defer ticker.Stop()
for {
select {
case <-db.stopCh:
// 延迟1秒
time.Sleep(1 * time.Second)
// 渲染
db.render()
return
case <-ticker.C:
db.render()
}
}
}()
})
}
// Close 关闭面板
func (db *DashboardPanel) Close() {
if db == nil {
return
}
db.closeOnce.Do(func() {
close(db.stopCh)
<-db.doneCh
fmt.Fprint(db.out, "\x1b[?25h") // 显示光标
})
}
// RegisterTask 注册任务信息
func (db *DashboardPanel) RegisterTask(id, path string, total int64, isFile bool) {
if db == nil || id == "" {
return
}
db.mu.Lock()
defer db.mu.Unlock()
task := db.getOrCreateTaskLocked(id)
if path != "" {
task.path = path
}
if task.name == "" {
task.name = baseName(path)
}
if total > 0 {
task.total = total
}
task.isFile = isFile
}
// UpdateTaskInfo 更新任务信息
func (db *DashboardPanel) UpdateTaskInfo(id, name string, total int64, isFile bool) {
if db == nil || id == "" {
return
}
db.mu.Lock()
defer db.mu.Unlock()
task := db.getOrCreateTaskLocked(id)
if name != "" {
task.name = name
}
if total > 0 {
task.total = total
}
task.isFile = isFile
}
// UpdateTaskProgress 更新任务进度
func (db *DashboardPanel) UpdateTaskProgress(id string, downloaded, total, speed int64, eta time.Duration) {
if db == nil || id == "" {
return
}
db.mu.Lock()
defer db.mu.Unlock()
task := db.getOrCreateTaskLocked(id)
task.downloaded = downloaded
if total > 0 {
task.total = total
}
task.speed = speed
task.eta = eta
}
// MarkTaskState 标记任务状态
func (db *DashboardPanel) MarkTaskState(id string, state TaskState, message string) {
if db == nil || id == "" {
return
}
db.mu.Lock()
defer db.mu.Unlock()
task := db.getOrCreateTaskLocked(id)
task.state = state
if message != "" {
task.message = message
}
switch state {
case TaskSuccess, TaskFailed, TaskSkipped, TaskCanceled:
db.appendHistoryLocked(db.defaultHistoryMessage(task, message))
}
}
// Logf 增加日志
func (db *DashboardPanel) Logf(format string, a ...interface{}) {
if db == nil {
return
}
msg := fmt.Sprintf(format, a...)
msg = strings.TrimRight(msg, "\r\n")
if msg == "" {
return
}
// 去掉开头的换行符
msg = strings.TrimPrefix(msg, "\n")
// 去掉回车换行符,UI面板的日志只能单行显示
msg = strings.ReplaceAll(msg, "\n", " ")
db.mu.Lock()
defer db.mu.Unlock()
db.appendHistoryLocked(msg)
}
func (db *DashboardPanel) getOrCreateTaskLocked(id string) *dashboardTask {
if task, ok := db.tasks[id]; ok {
return task
}
task := &dashboardTask{
id: id,
state: TaskQueued,
}
db.tasks[id] = task
db.order = append(db.order, id)
return task
}
func (db *DashboardPanel) appendHistoryLocked(message string) {
if message == "" {
return
}
timestamp := time.Now().Format("15:04:05")
db.history = append(db.history, fmt.Sprintf("[%s] %s", timestamp, message))
if len(db.history) > db.maxHistory*3 {
db.history = db.history[len(db.history)-db.maxHistory*2:]
}
}
func (db *DashboardPanel) defaultHistoryMessage(task *dashboardTask, message string) string {
if message != "" {
return message
}
name := task.path
if name == "" {
name = task.name
}
switch task.state {
case TaskSuccess:
return "完成: " + name
case TaskFailed:
return "失败: " + name
case TaskSkipped:
return "跳过: " + name
case TaskCanceled:
return "取消: " + name
default:
return name
}
}
// render 渲染面板
func (db *DashboardPanel) render() {
if db == nil {
return
}
width, height := terminalSize()
if width < 60 {
width = 60
}
innerWidth := width - 2
snapshot := db.snapshot()
// 统计总览
headerLines := db.headerLines(snapshot, innerWidth)
// 下载任务队列详情
//activeTitle := db.activeTitle(snapshot)
activeLines := db.activeLines(snapshot, innerWidth)
// 下载日志
headerHeight := len(headerLines) + 2
activeHeight := len(activeLines) + 1
historyBase := 3
minHistory := 1
minHeight := headerHeight + activeHeight + historyBase + minHistory
if height < minHeight {
height = minHeight
}
historyLines := height - (headerHeight + activeHeight + historyBase)
if historyLines < minHistory {
historyLines = minHistory
}
historyBody := db.historyLines(snapshot, innerWidth, historyLines)
// 拼接并格式化输出
builder := &strings.Builder{}
builder.WriteString(renderBox(headerLines, width))
builder.WriteString(renderTitledBox("ActiveDownloads", activeLines, width))
builder.WriteString(renderTitledBox("History", historyBody, width))
content := builder.String()
// 使用双缓冲技术
var buf strings.Builder
// 构建完整内容
buf.WriteString("\x1b[H\x1b[2J") // 清屏操作
buf.WriteString(content)
// 一次性输出
fmt.Fprint(db.out, buf.String())
}
type dashboardSnapshot struct {
tasks []*dashboardTask
history []string
}
func (db *DashboardPanel) snapshot() dashboardSnapshot {
db.mu.Lock()
defer db.mu.Unlock()
tasks := make([]*dashboardTask, 0, len(db.order))
for _, id := range db.order {
if task, ok := db.tasks[id]; ok {
copied := *task
tasks = append(tasks, &copied)
}
}
history := append([]string(nil), db.history...)
return dashboardSnapshot{tasks: tasks, history: history}
}
func renderBox(lines []string, width int) string {
if width < 4 {
width = 4
}
innerWidth := width - 2
builder := &strings.Builder{}
builder.WriteString("\u250c")
builder.WriteString(strings.Repeat("\u2500", innerWidth))
builder.WriteString("\u2510")
for _, line := range lines {
builder.WriteString("\n")
builder.WriteString("\u2502") // 左边框
builder.WriteString(fitLine(line, innerWidth))
builder.WriteString("\u2502") // 右边框
}
builder.WriteString("\n")
builder.WriteString("\u2502") // 左边框
//builder.WriteString("\u2514") // 左下角
builder.WriteString(strings.Repeat("\u2500", innerWidth))
builder.WriteString("\u2502") // 右边框
//builder.WriteString("\u2518") // 右下角
return builder.String()
}
func renderTitledBox(title string, body []string, width int) string {
if width < 4 {
width = 4
}
innerWidth := width - 2
builder := &strings.Builder{}
for _, line := range body {
builder.WriteString("\n")
builder.WriteString("\u2502") // 左边框
builder.WriteString(fitLine(line, innerWidth)) // 内容,使用空格补足宽度
builder.WriteString("\u2502") // 右边框
}
builder.WriteString("\n")
if strings.HasPrefix(title, "History") {
builder.WriteString("\u2514") // 左下角
} else {
builder.WriteString("\u2502") // 左边框
}
builder.WriteString(strings.Repeat("\u2500", innerWidth)) // 表格横线
if strings.HasPrefix(title, "History") {
builder.WriteString("\u2518") // 右下角
} else {
builder.WriteString("\u2502") // 右边框
}
return builder.String()
}
func (db *DashboardPanel) headerLines(snapshot dashboardSnapshot, innerWidth int) []string {
totalFiles, doneFiles, failedFiles := db.fileCounts(snapshot.tasks)
downloaded, total := db.totalProgress(snapshot.tasks)
speed := db.totalSpeed(snapshot.tasks)
elapsed := time.Since(db.startTime)
left := "-"
if speed > 0 && total > downloaded {
left = formatDurationShort(time.Duration((total-downloaded)/speed) * time.Second)
}
status := "进行中"
if db.dashboardType == DashboardPanelDownload {
status = "下载中"
} else if db.dashboardType == DashboardPanelUpload {
status = "上传中"
}
if totalFiles > 0 && doneFiles >= totalFiles {
if failedFiles > 0 {
status = "已完成 (有错误)"
} else {
status = "已完成"
}
}
line1 := fmt.Sprintf("总速度: %s | 文件: %d/%d | 失败: %d | 状态: %s | 已用时间: %s | 剩余时间: %s",
utils.FormatSpeedFixedWidth(speed, 11), doneFiles, totalFiles, failedFiles, status, formatDurationShort(elapsed), left)
line1 = fitLine(line1, innerWidth)
progressPct := 0.0
if total > 0 {
progressPct = float64(downloaded) / float64(total)
}
progress := fmt.Sprintf("%s/%s", converter.ConvertFileSize(downloaded, 2), converter.ConvertFileSize(total, 2))
label := "总进度: "
suffix := fmt.Sprintf(" %5.1f%% (%s)", progressPct*100, progress)
barWidth := innerWidth - displayWidth(label) - displayWidth(suffix) - 2
if barWidth < 10 {
barWidth = 10
}
bar := renderBar(progressPct, barWidth)
line2 := fmt.Sprintf("%s[%s]%s", label, bar, suffix)
line2 = fitLine(line2, innerWidth)
return []string{line1, line2}
}
func (db *DashboardPanel) activeTitle(snapshot dashboardSnapshot) string {
running := 0
queued := 0
for _, task := range snapshot.tasks {
if !task.isFile {
continue
}
switch task.state {
case TaskRunning:
running++
case TaskQueued:
queued++
}
}
return fmt.Sprintf("Active Downloads (slots %d, running %d, queued %d)", db.activeSlots, running, queued)
}
func (db *DashboardPanel) activeLines(snapshot dashboardSnapshot, innerWidth int) []string {
active := db.pickActiveTasks(snapshot.tasks)
lines := make([]string, 0, db.activeSlots)
for i := 0; i < db.activeSlots; i++ {
if i < len(active) {
lines = append(lines, renderTaskLine(active[i], i, innerWidth))
continue
}
placeholder := &dashboardTask{name: "[空闲...]", state: TaskQueued}
lines = append(lines, renderTaskLine(placeholder, i, innerWidth))
}
return lines
}
func (db *DashboardPanel) historyLines(snapshot dashboardSnapshot, innerWidth int, lines int) []string {
if lines < 1 {
lines = 1
}
maxLines := lines
if db.maxHistory > 0 && maxLines > db.maxHistory {
maxLines = db.maxHistory
}
if maxLines > len(snapshot.history) {
maxLines = len(snapshot.history)
}
result := make([]string, 0, lines)
start := len(snapshot.history) - maxLines
if start < 0 {
start = 0
}
for i := len(snapshot.history) - 1; i >= start && len(result) < maxLines; i-- {
result = append(result, truncateText(snapshot.history[i], innerWidth))
}
if len(result) == 0 {
result = append(result, truncateText("No recent events.", innerWidth))
}
// 倒序数组
reversedResult := make([]string, 0, len(result))
for i := len(result) - 1; i >= 0; i-- {
reversedResult = append(reversedResult, result[i])
}
result = reversedResult
// 补全空行
for len(result) < lines {
result = append(result, "")
}
return result[:lines]
}
func (db *DashboardPanel) fileCounts(tasks []*dashboardTask) (total int, done int, failed int) {
for _, task := range tasks {
if !task.isFile {
continue
}
total++
switch task.state {
case TaskSuccess, TaskSkipped, TaskFailed, TaskCanceled:
done++
}
if task.state == TaskFailed {
failed++
}
}
return
}
func (db *DashboardPanel) totalProgress(tasks []*dashboardTask) (downloaded int64, total int64) {
for _, task := range tasks {
if !task.isFile {
continue
}
if task.total > 0 {
total += task.total
if task.state == TaskSuccess || task.state == TaskSkipped {
// 成功、跳过的文件,统计上当做已经下载完成
downloaded += task.total
} else {
if task.downloaded >= task.total {
downloaded += task.total
} else {
downloaded += task.downloaded
}
}
}
}
return
}
func (db *DashboardPanel) totalSpeed(tasks []*dashboardTask) int64 {
if db.globalSpeeds != nil {
return db.globalSpeeds.GetSpeeds()
}
var sum int64
for _, task := range tasks {
sum += task.speed
}
return sum
}
func (db *DashboardPanel) pickActiveTasks(tasks []*dashboardTask) []*dashboardTask {
slotCap := db.activeSlots
if slotCap < 1 {
slotCap = 1
}
active := make([]*dashboardTask, 0, slotCap)
queued := make([]*dashboardTask, 0, slotCap)
for _, task := range tasks {
if !task.isFile {
continue
}
switch task.state {
case TaskRunning:
active = append(active, task)
case TaskQueued:
queued = append(queued, task)
}
}
active = append(active, queued...)
if len(active) > slotCap {
active = active[:slotCap]
}
return active
}
func renderTaskLine(task *dashboardTask, index int, width int) string {
if width < 20 {
width = 20
}
prefix := fmt.Sprintf("%2d. ", index+1)
name := task.name
if name == "" {
name = baseName(task.path)
}
// 百分比
progressPct := 0.0
percent := "--.-%"
if task.total > 0 {
progressPct = float64(task.downloaded) / float64(task.total)
percent = fmt.Sprintf("%5.1f%%", math.Min(100, progressPct*100)) // 将浮点数格式化为:总宽度 5 个字符,包含 1 位小数,后面加百分号
} else {
// 空闲队列左边多一个空格,对齐活跃队列
percent = " --.-%"
}
// 速度
rate := "-"
// 剩余时间
eta := "-"
switch task.state {
case TaskQueued:
rate = "等待中"
case TaskRunning:
if task.speed > 0 {
rate = converter.ConvertFileSize(task.speed, 2) + "/s"
} else {
rate = "0 B/s"
}
if task.eta > 0 {
eta = formatDurationShort(task.eta)
}
case TaskSuccess:
rate = "done"
case TaskFailed:
rate = "failed"
case TaskSkipped:
rate = "skipped"
case TaskCanceled:
rate = "canceled"
}
barWidth := 20
rateWidth := 12
etaWidth := 8
percentWidth := displayWidth(percent)
fixed := displayWidth(prefix) + 1 + 2 /*进度条左右[]括号*/ + barWidth + 1 + percentWidth + 1 + rateWidth + 1 + etaWidth
nameWidth := width - fixed
if nameWidth < 8 {
reduce := 8 - nameWidth
if barWidth > 10 {
cut := minInt(reduce, barWidth-10)
barWidth -= cut
reduce -= cut
}
if rateWidth > 8 && reduce > 0 {
cut := minInt(reduce, rateWidth-8)
rateWidth -= cut
reduce -= cut
}
if etaWidth > 5 && reduce > 0 {
cut := minInt(reduce, etaWidth-5)
etaWidth -= cut
reduce -= cut
}
fixed = displayWidth(prefix) + 1 /*1个空格分隔*/ + 2 /*进度条左右[]括号*/ + barWidth /*进度条长度*/ + 1 /*1个空格分隔*/ + percentWidth + 1 /*1个空格分隔*/ + rateWidth + 1 /*1个空格分隔*/ + etaWidth
nameWidth = width - fixed
if nameWidth < 4 {
nameWidth = 4
}
}
name = padRight(truncateText(name, nameWidth), nameWidth)
bar := renderBar(progressPct, barWidth)
rate = padRight(truncateText(rate, rateWidth), rateWidth)
eta = padRight(truncateText(eta, etaWidth), etaWidth)
line := fmt.Sprintf("%s%s [%s] %s %s %s", prefix, name, bar, percent, rate, eta)
return fitLine(line, width)
}
// renderBar 绘制进度条
func renderBar(pct float64, width int) string {
if width < 5 {
width = 5
}
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(math.Floor(pct * float64(width)))
// 已经下载的部分使用 => 表示,没有下载的部分使用 空格 表示
if filled >= 1 {
return strings.Repeat("=", filled-1) + ">" + strings.Repeat(" ", width-filled)
}
return strings.Repeat(" ", width-filled)
}
func formatDurationShort(d time.Duration) string {
if d <= 0 {
return "-"
}
seconds := int(d.Seconds())
if seconds < 60 {
return fmt.Sprintf("%ds", seconds)
}
minutes := seconds / 60
seconds = seconds % 60
if minutes < 60 {
return fmt.Sprintf("%dm%02ds", minutes, seconds)
}
hours := minutes / 60
minutes = minutes % 60
if hours < 100 {
return fmt.Sprintf("%dh%02dm", hours, minutes)
}
return fmt.Sprintf("%dh", hours)
}
func truncateText(text string, max int) string {
if max <= 0 || text == "" {
return ""
}
if displayWidth(text) <= max {
return text
}
if max <= 3 {
return takeWidth(text, max)
}
return takeWidth(text, max-3) + "..."
}
func padRight(text string, width int) string {
if width <= 0 {
return text
}
if displayWidth(text) >= width {
return text
}
return text + strings.Repeat(" ", width-displayWidth(text))
}
func fitLine(text string, width int) string {
return padRight(truncateText(text, width), width)
}
func displayWidth(text string) int {
width := 0
for _, r := range text {
width += runeWidth(r)
}
return width
}
func takeWidth(text string, max int) string {
if max <= 0 {
return ""
}
used := 0
for idx, r := range text {
w := runeWidth(r)
if used+w > max {
return text[:idx]
}
used += w
}
return text
}
func runeWidth(r rune) int {
if r == 0 {
return 0
}
if r < 0x1100 {
return 1
}
switch {
case r >= 0x1100 && r <= 0x115f,
r == 0x2329 || r == 0x232a,
r >= 0x2e80 && r <= 0xa4cf && r != 0x303f,
r >= 0xac00 && r <= 0xd7a3,
r >= 0xf900 && r <= 0xfaff,
r >= 0xfe10 && r <= 0xfe19,
r >= 0xfe30 && r <= 0xfe6f,
r >= 0xff00 && r <= 0xff60,
r >= 0xffe0 && r <= 0xffe6,
r >= 0x20000 && r <= 0x2fffd,
r >= 0x30000 && r <= 0x3fffd:
return 2
}
return 1
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func baseName(path string) string {
if path == "" {
return ""
}
path = strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(path, "/"); idx >= 0 {
return path[idx+1:]
}
return path
}