Files
arcrun-collector/cmd/arcrun-app/diagnostics_export.go
T
Leo e5d8cee58b feat(t213): 匯出診斷檔搬進 arcrun-app 本機端(phase 2,接手 t210 落地後的 Progress/FailureBreakdown)
t210(commit 5effcb2)已把「總量進度」「失敗分類」算好並寫進 status.json(現況快照,
不隨閒置歸零)——本次直接讀那兩個欄位,不另算一套(首頁與診斷檔同一組數字)。

新增:
- collector/cmd/arcrun-app/diagnostics_export.go
  - App.ExportDiagnostics():合併本機(daemon 版本/自我更新狀態/Progress/
    FailureBreakdown/略過檔案樣本)與雲端(每帳號打新端點 GET /portal/daemon/diagnostics,
    X-Arcrun-API-Key 認證)成一份 JSON,彈系統存檔對話框存下來
  - mergeDiagnostics 抽成無 IO 純函式,方便測試
- collector/cmd/arcrun-app/diagnostics_export_test.go:5 個離線測試,涵蓋
  同首頁數字/分類名稱原樣照抄不自己判斷/失敗檔名 basename-only/略過清單省略欄位/
  帳號層 cloud 與 cloud_error 互斥
- frontend/src/main.js:「版本與更新」頁加「疑難排解」卡片+匯出按鈕

守住的規則:
- Progress/FailureBreakdown 原樣接住 status.json,不重掃 manifest
- 分類名稱字串只認 collector/progress.go 的 ClassifyFailure,本檔不判斷任何分類
- 失敗檔名 basename-only:借用既有 buildSkipped() 輸出(本來就是 basename+白話標籤)

驗證(實跑,非設計稿):go build/vet/gofmt 全乾淨;go test ./...(collector+
arcrun-app)全過;check-cis.sh/check-render.sh 視覺機械閘全過;wails build 成功
產生含 ExportDiagnostics 的 bindings;拿本機真實 config.json(2 個真帳號)+真網路
跑 buildDiagnosticsPayload():真拿到 update_check.latest=v0.18.23、真列出本機
略過檔案(basename)、兩個真帳號誠實回 cloud_error(線上新端點還沒部署,與總管
實測結果一致)。未在真實 GUI 點過按鈕(本機正跑 production Arcrun.app,避免干擾)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:09:48 +08:00

193 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// diagnostics_export.go — 檢修孔的 daemon 端(t213 phase 2InkStoneCo 總管交辦,2026-08-08)。
//
// 為什麼要在這裡做,不在雲端 portal 網頁做(phase 1 調查結論):
// 舊的「匯出診斷檔給我們看」按鈕住在雲端 RAG Portal 網頁(matrix/arcrun 的
// GET /portal/data/diagnostics),在封測者的**瀏覽器**裡執行;而封測者電腦上跑的
// daemon(本檔所在的 arcrun-app)是完全獨立的另一個行程,瀏覽器對本機檔案系統零
// 存取權——那顆按鈕不管加多少雲端欄位都構不到本機資料。本檔把按鈕搬到 daemon
// 行程本體,本機這半直接讀 status.json,雲端那半改打新增的 X-Arcrun-API-Key 版
// 端點(GET /portal/daemon/diagnostics,免帳密,daemon 背景行程沒有 portal session)。
//
// 🔴 leo 08-08 三條追加規則:
// ① 首頁與診斷檔必須是同一組數字——本檔**只讀** status.json 裡 t210 已經算好的
// ProgressFailureBreakdown,不重新掃 manifest、不另算一套。
// ② 分類名稱字串只准住在 collector/progress.go 的 ClassifyFailure——本檔原樣照抄
// FailureBreakdown.Groups,不認得任何一個分類名(呼應 app.go buildProgress 的規矩)。
// ③ 失敗檔名只出 basename,不出完整路徑(完整路徑會洩漏使用者的資料夾結構)——
// 沿用首頁既有的 buildSkipped():它的 Files 欄位本來就是 filepath.Base()+白話標籤,
// 不是原始路徑,這裡直接借用,不重寫第二套。
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
collector "arcrun-rag/collector"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// diagnosticsHTTP:雲端這半只是一次 GET(只讀查詢,不佔 KV/D1 寫入額度),
// 15s 逾時給雲端冷啟動/LLM 相關端點的餘裕(比照 extract_workersai.go 的精神,
// 但這支端點不碰 AI,通常遠快於此,逾時值只是保底)。
var diagnosticsHTTP = &http.Client{Timeout: 15 * time.Second}
// localDiagnostics=地端這半:daemon 版本/自我更新狀態+t210 已算好的總量進度/
// 失敗分類+讀不了的檔案樣本(basename)。
type localDiagnostics struct {
DaemonVersion string `json:"daemon_version"`
UpdateCheck UpdateInfo `json:"update_check"` // 現查現答(Q4:Mac 卡在舊版)
Progress collector.SyncProgress `json:"progress"` // 同首頁(Q2 的分母:Total)
FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"` // 同首頁(Q3:分類統計)
// SkippedSampleSkippedMore=格式讀不了、根本沒進 manifest 的檔案樣本,
// 沿用 buildSkipped() 既有邏輯(basename+白話格式標籤,如「舊版報告.doc(舊版 Word)」);
// 沒有東西被略過時兩者都是零值,JSON 省略。
SkippedSample []string `json:"skipped_sample,omitempty"`
SkippedMore int `json:"skipped_more,omitempty"`
}
// accountDiagnostics=一個雲端帳號(知識庫實例)的雲端那半。
// Cloud 拿不到時填 CloudError(誠實回報,不假裝有數字)——常見原因:舊版雲端沒有
// /portal/daemon/diagnostics(部署還沒到)、網路不通、api key 尚未設定。
type accountDiagnostics struct {
InstanceName string `json:"instance_name"`
Host string `json:"host"`
Cloud map[string]any `json:"cloud,omitempty"`
CloudError string `json:"cloud_error,omitempty"`
}
// exportedDiagnostics=按鈕按下去存的那一份完整檔案。
type exportedDiagnostics struct {
GeneratedAt string `json:"generated_at"`
Local localDiagnostics `json:"local"`
Accounts []accountDiagnostics `json:"accounts"`
}
// fetchCloudDiagnosticsFn 是可在測試中替換的間接呼叫(比照 cloud_version.go 的
// fetchCloudVersion 慣例),讓 mergeDiagnostics 以外的 IO 邊界也能被單測頂替。
var fetchCloudDiagnosticsFn = fetchCloudDiagnostics
// fetchCloudDiagnostics 打雲端新端點 GET {cypherURL}/portal/daemon/diagnostics
// X-Arcrun-API-Key 認證,matrix/arcrun commit 93b1140)。回應原樣轉存(本身已守住
// 兩條紅線:不含內部概念/不含卡片內容,見 buildDiagnostics 註解),本函式不重新挑欄位。
func fetchCloudDiagnostics(cypherURL, apiKey string) (map[string]any, error) {
base := strings.TrimSpace(cypherURL)
if base == "" {
return nil, fmt.Errorf("這個帳號還沒設定知識庫網址")
}
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("這個帳號還沒有連線金鑰")
}
url := strings.TrimSuffix(base, "/") + "/portal/daemon/diagnostics"
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Arcrun-API-Key", apiKey)
resp, err := diagnosticsHTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("連不上你的知識庫:%w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusNotFound {
// 舊實例還沒有這條 route ⇒ 講人話,別讓診斷檔裡出現裸 404(比照 extract_workersai.go 慣例)
return nil, fmt.Errorf("你的知識庫還是舊版(沒有雲端診斷功能)⇒ 請到 portal 按「立即更新」重裝一次")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("雲端診斷查詢失敗(HTTP %d):%.300s", resp.StatusCode, string(body))
}
var out map[string]any
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("雲端回應解析失敗:%w", err)
}
return out, nil
}
// buildDiagnosticsPayload 只管 IO(讀 status.json/讀 config.json/打網路);
// 純合併邏輯抽進 mergeDiagnostics(無 IO),方便測試不必真的連網/彈存檔對話框
// 就能涵蓋「同一組數字」「分類原樣照抄」「basename-only」這幾條規則。
func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
sync := loadSyncStatus()
skipped := buildSkipped(sync) // 首頁既有邏輯:Files 已是 basename+白話標籤,直接借用
update := a.CheckUpdate() // 現查現答,不用可能過期的背景檢查快取(Q4)
cfg, _ := loadCfg()
accounts := make([]accountDiagnostics, 0, len(cfg.Accounts))
for _, acc := range cfg.Accounts {
ad := accountDiagnostics{InstanceName: accountName(acc), Host: shortHost(acc.CypherURL)}
key := acc.APIKey
if strings.TrimSpace(key) == "" {
key = acc.Namespace // 同 collector/direct.go 的既有 fallbackapi_key 空值時退回 namespace
}
cloud, err := fetchCloudDiagnosticsFn(acc.CypherURL, key)
if err != nil {
ad.CloudError = err.Error()
} else {
ad.Cloud = cloud
}
accounts = append(accounts, ad)
}
return mergeDiagnostics(sync, skipped, version, update, accounts)
}
// mergeDiagnostics 純函式:把已經各自拿到的本機/雲端資料組成最終輸出形狀,不做任何 IO。
//
// leo 08-08 規則對照:
// ① 同首頁數字——ProgressFailureBreakdown 原樣接住 sync 裡 t210 已算好的值,不重算。
// ② 分類名稱只認得 collector/progress.go 的 ClassifyFailure——這裡原樣照抄
// sync.FailureBreakdown,本函式不比對/不認得任何一個分類字串。
// ③ 失敗檔名只出 basename——skipped.Files 沿用 buildSkipped() 既有輸出(本來就是
// filepath.Base()+白話標籤),本函式不重新處理路徑。
func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, update UpdateInfo, accounts []accountDiagnostics) exportedDiagnostics {
local := localDiagnostics{
DaemonVersion: daemonVersion,
UpdateCheck: update,
Progress: sync.Progress,
FailureBreakdown: sync.FailureBreakdown,
}
if skipped != nil {
local.SkippedSample = skipped.Files
local.SkippedMore = skipped.More
}
return exportedDiagnostics{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Local: local,
Accounts: accounts,
}
}
// ExportDiagnostics(前端「疑難排解」按鈕呼叫):組出診斷內容 → 彈系統存檔對話框 →
// 使用者選好位置就寫檔。回傳實際寫入的路徑(前端顯示「已存到 xxx」);
// 使用者取消對話框時回傳空字串+nil(不算錯誤,不彈紅字嚇人)。
func (a *App) ExportDiagnostics() (string, error) {
payload := a.buildDiagnosticsPayload()
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return "", err
}
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "匯出診斷檔",
DefaultFilename: fmt.Sprintf("arcrun-diagnostics-%s.json", time.Now().Format("2006-01-02-15-04-05")),
})
if err != nil {
return "", err
}
if path == "" {
return "", nil // 使用者按了取消
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", err
}
return path, nil
}