188 lines
5.7 KiB
Go
188 lines
5.7 KiB
Go
package render
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
|
||
)
|
||
|
||
// Kind 返回卡片标题用的中文名称。流量同步有日、月历史、本月快照三条链路,
|
||
// 标题必须能区分,否则运营无法从卡片判断是哪一类同步。
|
||
func Kind(operation string) string {
|
||
switch operation {
|
||
case "previous_day_usage_full_sync":
|
||
return "日流量同步"
|
||
case "previous_month_usage_full_sync":
|
||
return "月历史同步"
|
||
case "current_month_usage_full_sync":
|
||
return "本月流量快照同步"
|
||
}
|
||
if strings.Contains(operation, "sim_status") {
|
||
return "卡状态全量同步"
|
||
}
|
||
if isTrafficUsage(operation) {
|
||
return "流量全量同步"
|
||
}
|
||
return operation
|
||
}
|
||
|
||
// isTrafficUsage 判定 operation 是否属于流量族(日、月历史、本月快照及兼容旧操作名)。
|
||
// 供料观测等按链路区分的行为用它判断,避免依赖 Kind 返回的中文标题。
|
||
func isTrafficUsage(operation string) bool {
|
||
return strings.Contains(operation, "traffic") || strings.Contains(operation, "usage")
|
||
}
|
||
|
||
func Text(a archive.RunArchive, environment string) string {
|
||
title := fmt.Sprintf("【%s|%s】", environment, Kind(a.Run.Operation))
|
||
startAt, endAt := executionTimes(a)
|
||
lines := []string{
|
||
title,
|
||
"",
|
||
fmt.Sprintf("总数:%s", number(a.TotalCount())),
|
||
fmt.Sprintf("成功:%s", number(a.Run.SuccessCount)),
|
||
fmt.Sprintf("失败:%s", number(a.Run.FailedCount)),
|
||
fmt.Sprintf("成功率:%.2f%%", a.SuccessRate()),
|
||
fmt.Sprintf("开始时间:%s", timestamp(startAt)),
|
||
fmt.Sprintf("结束时间:%s", timestamp(endAt)),
|
||
fmt.Sprintf("总耗时:%s", duration(a.Duration())),
|
||
fmt.Sprintf("实际执行:%d 轮", len(a.Rounds)),
|
||
"",
|
||
"轮次结果:",
|
||
}
|
||
for _, r := range a.Rounds {
|
||
lines = append(lines, roundLine(r))
|
||
}
|
||
if block := supplyBlock(a); block != "" {
|
||
lines = append(lines, "", block)
|
||
}
|
||
lines = append(lines, "", "Run:"+a.Run.RunID)
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func Card(a archive.RunArchive, environment string) ([]byte, error) {
|
||
title := fmt.Sprintf("%s · %s", displayEnvironment(environment), Kind(a.Run.Operation))
|
||
startAt, endAt := executionTimes(a)
|
||
// Full sync runs commonly have final failures; use one neutral header color.
|
||
template := "blue"
|
||
elements := []any{
|
||
fields(
|
||
field("总数", number(a.TotalCount())),
|
||
field("成功", number(a.Run.SuccessCount)),
|
||
field("失败", number(a.Run.FailedCount)),
|
||
field("成功率", fmt.Sprintf("%.2f%%", a.SuccessRate())),
|
||
field("开始时间", timestamp(startAt)),
|
||
field("结束时间", timestamp(endAt)),
|
||
field("总耗时", duration(a.Duration())),
|
||
field("实际轮次", fmt.Sprintf("%d轮", len(a.Rounds))),
|
||
),
|
||
map[string]any{"tag": "hr"},
|
||
}
|
||
for _, r := range a.Rounds {
|
||
elements = append(elements, map[string]any{
|
||
"tag": "div",
|
||
"text": map[string]any{"tag": "lark_md", "content": roundBlock(r)},
|
||
})
|
||
}
|
||
if block := supplyBlock(a); block != "" {
|
||
elements = append(elements, map[string]any{"tag": "div", "text": map[string]any{"tag": "lark_md", "content": block}})
|
||
}
|
||
elements = append(elements,
|
||
map[string]any{"tag": "hr"},
|
||
map[string]any{"tag": "note", "elements": []any{map[string]any{"tag": "plain_text", "content": "Run: " + a.Run.RunID}}},
|
||
)
|
||
card := map[string]any{
|
||
"msg_type": "interactive",
|
||
"card": map[string]any{
|
||
"header": map[string]any{"template": template, "title": map[string]any{"tag": "plain_text", "content": title}},
|
||
"elements": elements,
|
||
},
|
||
}
|
||
return json.Marshal(card)
|
||
}
|
||
|
||
func fields(values ...map[string]any) map[string]any {
|
||
entries := make([]any, len(values))
|
||
for i, value := range values {
|
||
entries[i] = value
|
||
}
|
||
return map[string]any{"tag": "div", "fields": entries}
|
||
}
|
||
|
||
func field(label, value string) map[string]any {
|
||
return map[string]any{
|
||
"is_short": true,
|
||
"text": map[string]any{"tag": "lark_md", "content": fmt.Sprintf("**%s**\n%s", label, value)},
|
||
}
|
||
}
|
||
|
||
func roundBlock(r archive.RoundStats) string {
|
||
return fmt.Sprintf("**第 %d 轮 · %s 张 · %s**\n成功 %s(%.2f%%)\n进入下轮 %s · 终态失败 %s", r.Round, number(r.EnqueuedCount), duration(r.Duration()), number(r.SuccessCount), r.SuccessRate(), number(r.NextRoundCount), number(r.FailedCount))
|
||
}
|
||
|
||
func displayEnvironment(environment string) string {
|
||
switch environment {
|
||
case "preview":
|
||
return "预演"
|
||
case "prod", "production":
|
||
return "生产"
|
||
default:
|
||
return environment
|
||
}
|
||
}
|
||
|
||
func roundLine(r archive.RoundStats) string {
|
||
return fmt.Sprintf("第 %d 轮|%s 张|成功 %s(%.2f%%)|进入下轮 %s|终态失败 %s|%s", r.Round, number(r.EnqueuedCount), number(r.SuccessCount), r.SuccessRate(), number(r.NextRoundCount), number(r.FailedCount), duration(r.Duration()))
|
||
}
|
||
|
||
func executionTimes(a archive.RunArchive) (string, string) {
|
||
if len(a.Rounds) == 0 {
|
||
return a.Run.CreatedAt, a.Run.CompletedAt
|
||
}
|
||
return a.Rounds[0].StartedAt, a.Rounds[len(a.Rounds)-1].CompletedAt
|
||
}
|
||
|
||
func timestamp(value string) string {
|
||
t, err := time.Parse(time.RFC3339Nano, value)
|
||
if err != nil {
|
||
return value
|
||
}
|
||
return t.In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05")
|
||
}
|
||
|
||
func number(n int64) string {
|
||
negative := n < 0
|
||
if negative {
|
||
n = -n
|
||
}
|
||
s := fmt.Sprintf("%d", n)
|
||
for i := len(s) - 3; i > 0; i -= 3 {
|
||
s = s[:i] + "," + s[i:]
|
||
}
|
||
if negative {
|
||
return "-" + s
|
||
}
|
||
return s
|
||
}
|
||
|
||
func duration(d time.Duration) string {
|
||
seconds := int64(d / time.Second)
|
||
if seconds < 0 {
|
||
return "未记录"
|
||
}
|
||
h, m, s := seconds/3600, (seconds%3600)/60, seconds%60
|
||
parts := make([]string, 0, 3)
|
||
if h > 0 {
|
||
parts = append(parts, fmt.Sprintf("%d小时", h))
|
||
}
|
||
if m > 0 {
|
||
parts = append(parts, fmt.Sprintf("%d分", m))
|
||
}
|
||
if s > 0 || len(parts) == 0 {
|
||
parts = append(parts, fmt.Sprintf("%d秒", s))
|
||
}
|
||
return strings.Join(parts, "")
|
||
}
|