chore: 初始开源发布
iotlcm-bot:独立飞书应用机器人(读取 Worker 终态 Run JSON,渲染并发送全量同步结果卡片)。
- Go module:git.5iot.com/iotlcm/iotlcm-bot → git.5iot.com/iotlcm-public/iotlcm-bot
- 飞书凭据占位:appId/appSecret/chatId → ${FEISHU_APP_ID}/${FEISHU_APP_SECRET}/${FEISHU_CHAT_ID}
- 移除内部资产:CI 触发器
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → scratch)
- README 重写(命令/配置/挂载契约/常见问题),去掉内部环境路径
- 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Alert 描述一条运维告警。
|
||||
type Alert struct {
|
||||
Environment string
|
||||
// Title 是卡片标题,如「归档目录不可用」。
|
||||
Title string
|
||||
// Summary 是一句人话结论,如「正在重启以重新挂载」。
|
||||
Summary string
|
||||
// Reason 是技术细节(英文错误原样保留,便于排查)。
|
||||
Reason string
|
||||
// ExitCount 为累计退出次数,>1 时提示可能反复出现。
|
||||
ExitCount int
|
||||
// Path 是涉及的路径。
|
||||
Path string
|
||||
}
|
||||
|
||||
// AlertCard 把告警渲染成飞书交互卡片。
|
||||
//
|
||||
// 为什么不用纯文本:text 消息在飞书里渲染成灰底等宽块,长错误串会把气泡撑得很宽,
|
||||
// 而且和同步结果卡片(interactive)风格不一致。卡片可以带红色 header、分区字段,
|
||||
// 一眼能看出是「出事了」而不是一条普通消息。
|
||||
func AlertCard(a Alert) ([]byte, error) {
|
||||
title := fmt.Sprintf("%s · %s", displayEnvironment(a.Environment), a.Title)
|
||||
|
||||
fields := []any{}
|
||||
if a.Summary != "" {
|
||||
fields = append(fields, map[string]any{
|
||||
"tag": "div",
|
||||
"text": map[string]any{"tag": "lark_md", "content": "**" + a.Summary + "**"},
|
||||
})
|
||||
}
|
||||
if a.Path != "" {
|
||||
fields = append(fields, map[string]any{
|
||||
"tag": "div",
|
||||
"text": map[string]any{"tag": "lark_md", "content": "**路径**\n`" + a.Path + "`"},
|
||||
})
|
||||
}
|
||||
if a.Reason != "" {
|
||||
fields = append(fields, map[string]any{
|
||||
"tag": "div",
|
||||
"text": map[string]any{"tag": "lark_md", "content": "**原因**\n" + a.Reason},
|
||||
})
|
||||
}
|
||||
if a.ExitCount > 1 {
|
||||
fields = append(fields, map[string]any{
|
||||
"tag": "div",
|
||||
"text": map[string]any{"tag": "lark_md", "content": fmt.Sprintf("**已重复 %d 次**,若持续出现请检查宿主机上该目录是否被反复删除。", a.ExitCount)},
|
||||
})
|
||||
}
|
||||
fields = append(fields, map[string]any{"tag": "hr"})
|
||||
fields = append(fields, map[string]any{
|
||||
"tag": "note",
|
||||
"elements": []any{
|
||||
map[string]any{"tag": "plain_text", "content": "iotlcm-bot 自动检测 · 恢复后不再重复提醒"},
|
||||
},
|
||||
})
|
||||
|
||||
card := map[string]any{
|
||||
"msg_type": "interactive",
|
||||
"card": map[string]any{
|
||||
// red 让告警在消息流里一眼可辨,与结果卡片的 blue 区分开。
|
||||
"header": map[string]any{"template": "red", "title": map[string]any{"tag": "plain_text", "content": title}},
|
||||
"elements": fields,
|
||||
},
|
||||
}
|
||||
return json.Marshal(card)
|
||||
}
|
||||
|
||||
// FriendlyReason 把常见的技术错误翻成人话,保留原文以便排查。
|
||||
func FriendlyReason(reason string) string {
|
||||
switch {
|
||||
case strings.Contains(reason, "deleted on the host"):
|
||||
return "宿主机上的归档目录已被删除,容器仍指向旧目录(挂载失效)。"
|
||||
case strings.Contains(reason, "no such file or directory"):
|
||||
return "目录不存在。"
|
||||
case strings.Contains(reason, "permission denied"):
|
||||
return "没有读取权限。"
|
||||
case strings.Contains(reason, "not a directory"):
|
||||
return "该路径不是目录。"
|
||||
default:
|
||||
return reason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlertCardIsInteractiveWithRedHeader(t *testing.T) {
|
||||
body, err := AlertCard(Alert{
|
||||
Environment: "preview",
|
||||
Title: "归档目录不可用",
|
||||
Summary: "正在重启以重新挂载",
|
||||
Reason: "宿主机上的归档目录已被删除,容器仍指向旧目录(挂载失效)。",
|
||||
Path: "/data/full-sync",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
t.Fatalf("alert card must be valid JSON: %v", err)
|
||||
}
|
||||
if decoded["msg_type"] != "interactive" {
|
||||
t.Fatalf("alert must be an interactive card, got %v", decoded["msg_type"])
|
||||
}
|
||||
card := decoded["card"].(map[string]any)
|
||||
header := card["header"].(map[string]any)
|
||||
if header["template"] != "red" {
|
||||
t.Fatalf("alert header should be red so it stands out, got %v", header["template"])
|
||||
}
|
||||
title := header["title"].(map[string]any)["content"].(string)
|
||||
if !strings.Contains(title, "预演") || !strings.Contains(title, "归档目录不可用") {
|
||||
t.Fatalf("unexpected title: %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertCardShowsPathAndRepeatCount(t *testing.T) {
|
||||
body, err := AlertCard(Alert{
|
||||
Environment: "dev",
|
||||
Title: "归档目录不可用",
|
||||
Summary: "正在重启以重新挂载",
|
||||
Reason: "目录不存在。",
|
||||
Path: "/data/full-sync",
|
||||
ExitCount: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(body)
|
||||
if !strings.Contains(text, "/data/full-sync") {
|
||||
t.Fatal("card should show the path")
|
||||
}
|
||||
if !strings.Contains(text, "已重复 3 次") {
|
||||
t.Fatal("card should warn about repeated exits")
|
||||
}
|
||||
}
|
||||
|
||||
// 单次故障时不该出现「已重复」这种话,避免误导。
|
||||
func TestAlertCardOmitsRepeatNoticeOnFirstOccurrence(t *testing.T) {
|
||||
body, err := AlertCard(Alert{Environment: "dev", Title: "归档目录不可用", ExitCount: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), "已重复") {
|
||||
t.Fatal("first occurrence must not claim repeats")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendlyReasonTranslatesKnownErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"archive directory was deleted on the host (nlink=0); recreate this container": "宿主机上的归档目录已被删除",
|
||||
"stat /data/x: no such file or directory": "目录不存在。",
|
||||
"open /data/x: permission denied": "没有读取权限。",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := FriendlyReason(input); !strings.Contains(got, want) {
|
||||
t.Fatalf("FriendlyReason(%q) = %q, want it to contain %q", input, got, want)
|
||||
}
|
||||
}
|
||||
// 未知错误原样保留,便于排查
|
||||
unknown := "some unexpected failure"
|
||||
if got := FriendlyReason(unknown); got != unknown {
|
||||
t.Fatalf("unknown reason must be preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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, "")
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
|
||||
)
|
||||
|
||||
func sample() archive.RunArchive {
|
||||
return archive.RunArchive{Version: 1, Run: archive.RunSummary{RunID: "run-1", Operation: "sim_status_queue_full_sync", Status: "completed_with_failures", SuccessCount: 175893, FailedCount: 10504, CreatedAt: "2026-09-22T11:42:33Z", CompletedAt: "2026-09-22T12:32:01Z"}, Rounds: []archive.RoundStats{
|
||||
{Round: 1, EnqueuedCount: 186397, NextRoundCount: 9023, SuccessCount: 175870, FailedCount: 1504, StartedAt: "2026-09-22T11:42:34Z", CompletedAt: "2026-09-22T12:21:49Z"},
|
||||
{Round: 2, EnqueuedCount: 9023, NextRoundCount: 9000, SuccessCount: 23, FailedCount: 0, StartedAt: "2026-09-22T12:21:51Z", CompletedAt: "2026-09-22T12:26:55Z"},
|
||||
{Round: 3, EnqueuedCount: 9000, NextRoundCount: 0, SuccessCount: 0, FailedCount: 9000, StartedAt: "2026-09-22T12:26:56Z", CompletedAt: "2026-09-22T12:32:01Z"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestKindNamesEachSyncType(t *testing.T) {
|
||||
cases := []struct{ operation, want string }{
|
||||
{"previous_day_usage_full_sync", "日流量同步"},
|
||||
{"previous_month_usage_full_sync", "月历史同步"},
|
||||
{"current_month_usage_full_sync", "本月流量快照同步"},
|
||||
{"sim_status_queue_full_sync", "卡状态全量同步"},
|
||||
{"traffic_usage_full_sync", "流量全量同步"},
|
||||
{"usage_full_sync", "流量全量同步"},
|
||||
{"imei_full_sync", "imei_full_sync"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Kind(c.operation); got != c.want {
|
||||
t.Errorf("Kind(%q) = %q, want %q", c.operation, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindKeepsTrafficSyncTitlesDistinct(t *testing.T) {
|
||||
seen := map[string]string{}
|
||||
for _, operation := range []string{"previous_day_usage_full_sync", "previous_month_usage_full_sync", "current_month_usage_full_sync"} {
|
||||
title := Kind(operation)
|
||||
if other, exists := seen[title]; exists {
|
||||
t.Fatalf("%q and %q share title %q", other, operation, title)
|
||||
}
|
||||
seen[title] = operation
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardTitleNamesTrafficSyncType(t *testing.T) {
|
||||
cases := []struct{ operation, want string }{
|
||||
{"previous_day_usage_full_sync", "生产 · 日流量同步"},
|
||||
{"previous_month_usage_full_sync", "生产 · 月历史同步"},
|
||||
{"current_month_usage_full_sync", "生产 · 本月流量快照同步"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
a := archive.RunArchive{Version: 1, Run: archive.RunSummary{RunID: "run-title", Operation: c.operation}}
|
||||
body, err := Card(a, "prod")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"content":"`+c.want+`"`) {
|
||||
t.Fatalf("card for %q missing title %q: %s", c.operation, c.want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextContainsRoundThreeFailure(t *testing.T) {
|
||||
text := Text(sample(), "preview")
|
||||
for _, want := range []string{"开始时间:2026-09-22 19:42:34", "结束时间:2026-09-22 20:32:01", "实际执行:3 轮", "第 3 轮", "终态失败 9,000", "5分5秒", "0.00%"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("text missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "2026-09-22 20:26:56") {
|
||||
t.Fatalf("text exposes per-round start time: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardIsStructuredAndShowsFullRunID(t *testing.T) {
|
||||
body, err := Card(sample(), "preview")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := string(body)
|
||||
for _, want := range []string{
|
||||
`"msg_type":"interactive"`,
|
||||
`"content":"预演 · 卡状态全量同步"`,
|
||||
`"fields"`,
|
||||
`**开始时间**\n2026-09-22 19:42:34`,
|
||||
`**结束时间**\n2026-09-22 20:32:01`,
|
||||
`第 3 轮 · 9,000 张 · 5分5秒`,
|
||||
`进入下轮 0 · 终态失败 9,000`,
|
||||
`Run: run-1`,
|
||||
} {
|
||||
if !strings.Contains(value, want) {
|
||||
t.Fatalf("card missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(value, "2026-09-22 20:26:56") {
|
||||
t.Fatalf("card exposes per-round start time: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardShowsFullRunID(t *testing.T) {
|
||||
runID := "sim-status-20260922194233-c96b21ec7d3746ae9caef457e89faa41"
|
||||
card, err := Card(archive.RunArchive{Version: 1, Run: archive.RunSummary{RunID: runID}}, "preview")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(card), "Run: "+runID) {
|
||||
t.Fatalf("card truncated Run ID: %s", card)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func supplyBlock(a archive.RunArchive) string {
|
||||
if !isTrafficUsage(a.Run.Operation) || a.Run.SyncType == "replay" {
|
||||
return ""
|
||||
}
|
||||
s, present := a.Run.Supply()
|
||||
if !present {
|
||||
return ""
|
||||
}
|
||||
if s == nil {
|
||||
return "供料观测:不可用(遥测格式非法);业务结果不受影响"
|
||||
}
|
||||
status := []string{}
|
||||
if s.Partial {
|
||||
status = append(status, "partial")
|
||||
}
|
||||
if s.Truncated {
|
||||
status = append(status, "truncated")
|
||||
}
|
||||
if len(status) == 0 {
|
||||
status = append(status, "完整")
|
||||
}
|
||||
unknown, unconfirmed := s.Summary.UnknownMS, s.Summary.UnconfirmedMS
|
||||
lines := []string{"供料观测(Run 首轮扫描窗口,约1秒分辨率,时间为采样估计)", fmt.Sprintf("目标 %d · 有效 %d · 确认缺料 %d · unknown 通道 %d", s.Summary.Targets, s.Summary.Valid, s.Summary.Confirmed, s.Summary.Unknown), fmt.Sprintf("最长中断 %.1f秒 · 累计 %.1f通道秒(允许通道重叠)", float64(s.Summary.LongestMS)/1000, float64(s.Summary.TotalMS)/1000), fmt.Sprintf("待确认空闲 %.1f通道秒 · unknown %.1f通道秒 · %s", float64(unconfirmed)/1000, float64(unknown)/1000, strings.Join(status, " / "))}
|
||||
if s.Truncated {
|
||||
lines = append(lines, "明细已截断;Run摘要覆盖全部目标,归档保留最长等待前100通道")
|
||||
}
|
||||
if s.Summary.Confirmed == 0 && !s.Partial && !s.Truncated && s.Summary.Unknown == 0 && unconfirmed == 0 && s.Summary.Valid == s.Summary.Targets {
|
||||
lines = append(lines, "约1秒分辨率下未发现确认缺料")
|
||||
}
|
||||
channels := append([]archive.SupplyChannel(nil), s.Channels...)
|
||||
sort.Slice(channels, func(i, j int) bool {
|
||||
a, b := channels[i], channels[j]
|
||||
av, bv := max(a.Startup.LongestMS, a.Confirmed.LongestMS), max(b.Startup.LongestMS, b.Confirmed.LongestMS)
|
||||
if av == bv {
|
||||
return a.Key < b.Key
|
||||
}
|
||||
return av > bv
|
||||
})
|
||||
for _, c := range channels[:min(5, len(channels))] {
|
||||
longest := max(c.Startup.LongestMS, c.Confirmed.LongestMS)
|
||||
if longest == 0 {
|
||||
continue
|
||||
}
|
||||
phase := "未记录"
|
||||
var phaseMS int64
|
||||
for k, v := range c.Stages {
|
||||
if v > phaseMS || (v == phaseMS && k < phase) {
|
||||
phase, phaseMS = k, v
|
||||
}
|
||||
}
|
||||
signal := ""
|
||||
if longest >= 5000 {
|
||||
signal = "(≥5秒排查信号)"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s:最长 %.1f秒%s;启动 %.1f秒 / 后续 %.1f秒;同期主要生产者阶段 %s %.1f秒", c.Key, float64(longest)/1000, signal, float64(c.Startup.TotalMS)/1000, float64(c.Confirmed.TotalMS)/1000, phase, float64(phaseMS)/1000))
|
||||
}
|
||||
lines = append(lines, "小于采样间隔的中断可能漏检;同期阶段不证明因果,5秒提示不构成SLA。完整明细见归档。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-bot/internal/archive"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSupplyBlockCoversEveryTrafficOperation(t *testing.T) {
|
||||
observation := json.RawMessage(`{"schemaVersion":1,"resolutionMs":1000,"summary":{"targetChannels":1,"validChannels":1,"confirmedChannels":1,"confirmedChannelMs":6000,"longestMs":6000}}`)
|
||||
for _, operation := range []string{"previous_day_usage_full_sync", "previous_month_usage_full_sync", "current_month_usage_full_sync", "traffic_usage_full_sync"} {
|
||||
a := archive.RunArchive{Run: archive.RunSummary{Operation: operation, SupplyObservation: observation}}
|
||||
if supplyBlock(a) == "" {
|
||||
t.Fatalf("traffic operation %q must show supply block", operation)
|
||||
}
|
||||
}
|
||||
for _, operation := range []string{"sim_status_queue_full_sync", "imei_full_sync"} {
|
||||
a := archive.RunArchive{Run: archive.RunSummary{Operation: operation, SupplyObservation: observation}}
|
||||
if supplyBlock(a) != "" {
|
||||
t.Fatalf("non-traffic operation %q must omit supply block", operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupplyCompatibilityAndPartial(t *testing.T) {
|
||||
a := archive.RunArchive{Run: archive.RunSummary{Operation: "current_month_usage_full_sync"}}
|
||||
if supplyBlock(a) != "" {
|
||||
t.Fatal("legacy archive must omit telemetry")
|
||||
}
|
||||
a.Run.SupplyObservation = json.RawMessage(`{"schemaVersion":1,"resolutionMs":1000,"partial":true,"summary":{"targetChannels":2,"validChannels":2,"confirmedChannels":1,"confirmedChannelMs":6000,"longestMs":6000},"channels":[{"chanKey":"a","confirmedWait":{"totalMs":6000,"longestMs":6000},"producerStageMs":{"global_capacity":6000}}]}`)
|
||||
text := supplyBlock(a)
|
||||
for _, want := range []string{"partial", "6.0通道秒", "global_capacity", "约1秒分辨率", "≥5秒"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("missing %s in %s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "全程供料充足") {
|
||||
t.Fatal("unsupported conclusion")
|
||||
}
|
||||
a.Run.SupplyObservation = json.RawMessage(`"bad"`)
|
||||
if !strings.Contains(supplyBlock(a), "不可用") {
|
||||
t.Fatal("bad optional telemetry must degrade")
|
||||
}
|
||||
a.Run.SyncType = "replay"
|
||||
if supplyBlock(a) != "" {
|
||||
t.Fatal("replay changed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user