0c6fb43117
engine.detail/engine.last_error 在同步引擎沒在跑時,會把 collector 開機 橫幅的完整原文帶出來(direct.go RunDirect 的「監看 %s → %s」那行),其中 含監看資料夾的絕對路徑(如 /Users/xxx/Desktop/...)。這是上一輪(第二/ 三輪)已發現但標記「附帶發現、未修」的洞——首頁在同一狀態下本來就會顯示 同一句話(不動,留給另案判斷),但診斷檔現在會被匯出成檔案交給外部人看, 風險層級跟留在托盤畫面上不同,這輪把它遮掉。 新增 redactLocalPaths(),只在 buildDiagnosticsPayload() 組裝 engine 欄位時 套用:把訊息裡看起來像本機絕對路徑的片段換成「…/<路徑最後一截>」, http(s) URL 先放行原文(避免 URL 裡的 / 被誤判成本機路徑)。只動 diagnostics_export.go 這一端,不改 describeStatus()/collectorFailure()/ direct.go 的訊息本身——那些同時是首頁托盤畫面在用的同一組憑據。 擴充 diagnostics_engine_e2e_test.go(真執行檔+真 supervisor 子行程,零 網路零帳號):監看資料夾改用真實絕對路徑,證明子行程停擺後整份匯出 JSON 找不到該路徑,同時 last_error/detail 仍讀得出「引擎沒在跑、以及為什麼」 (含監看資料夾的名字)。另加 8 個 redactLocalPaths 純函式單測。 go build/go vet/go test ./...(collector+arcrun-app+supervisor 三個 package)全綠;diagnostics_stage_manual_test.go 維持預設 SKIP,本輪未碰 網路/stage。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
411 lines
20 KiB
Go
411 lines
20 KiB
Go
package main
|
||
|
||
// diagnostics_export.go — 檢修孔的 daemon 端(t213 phase 2,InkStoneCo 總管交辦,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 已經算好的
|
||
// Progress/FailureBreakdown,不重新掃 manifest、不另算一套。
|
||
// ② 分類名稱字串只准住在 collector/progress.go 的 ClassifyFailure——本檔原樣照抄
|
||
// FailureBreakdown.Groups,不認得任何一個分類名(呼應 app.go buildProgress 的規矩)。
|
||
// ③ 失敗檔名只出 basename,不出完整路徑(完整路徑會洩漏使用者的資料夾結構)——
|
||
// 沿用首頁既有的 buildSkipped():它的 Files 欄位本來就是 filepath.Base()+白話標籤,
|
||
// 不是原始路徑,這裡直接借用,不重寫第二套。
|
||
// ④(t213 phase 4,08-08 補)engine.detail/engine.last_error 一樣不准出現本機絕對
|
||
// 路徑——同③的理由,只是這次的路徑來源是 supervisor 捕到的 stderr 末行(collector
|
||
// 啟動時印的「監看 <資料夾路徑>」那行),不是檔名。做法見 redactLocalPaths():換成
|
||
// 「…/<路徑最後一截>」,不是整句吃掉——「引擎沒在跑、以及為什麼」仍要答得出來。
|
||
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)+t213 phase 3 補的 Engine(Q2 缺口)。
|
||
type localDiagnostics struct {
|
||
DaemonVersion string `json:"daemon_version"`
|
||
UpdateCheck UpdateInfo `json:"update_check"` // 現查現答(Q4:Mac 卡在舊版)
|
||
Engine engineDiagnostics `json:"engine"` // t213 phase 3(Q2:還在不在跑)
|
||
Progress collector.SyncProgress `json:"progress"` // 同首頁(Q2 的分母:Total)
|
||
FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"` // 同首頁(Q3:分類統計)
|
||
// SkippedSample/SkippedMore=格式讀不了、根本沒進 manifest 的檔案樣本,
|
||
// 沿用 buildSkipped() 既有邏輯(basename+白話格式標籤,如「舊版報告.doc(舊版 Word)」);
|
||
// 沒有東西被略過時兩者都是零值,JSON 省略。
|
||
SkippedSample []string `json:"skipped_sample,omitempty"`
|
||
SkippedMore int `json:"skipped_more,omitempty"`
|
||
}
|
||
|
||
// engineDiagnostics 回答「這台機器現在到底還有沒有在動」
|
||
// (t213 phase 3,2026-08-08 補考卷 Q2 缺口:封測者 Evan 原話「是壞了嗎?還是繼續在跑?」——
|
||
// 舊版診斷檔看得到 pending=0,卻分不出「真的做完了」還是「daemon 早就掛了沒人知道」,
|
||
// 因為完全沒有任何時間資訊。)
|
||
//
|
||
// 🔴 不發明第二套判斷法:Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail
|
||
// 直接借用 supervise.go 既有的 collectorAlive()/collectorSyncing()/collectorFailure(),
|
||
// 以及 app.go 首頁狀態列用的 describeStatus()——同一套憑據,這裡只是把它們也寫進 JSON,
|
||
// 確保首頁與診斷檔永遠是同一組真相(leo 08-08 紅線①),不是另外重新猜一套「活著嗎」。
|
||
type engineDiagnostics struct {
|
||
Alive bool `json:"alive"` // 同步引擎子行程現在活著嗎(supervisor 狀態機,非 pgrep,見 supervise.go collectorAlive() 註解)
|
||
Syncing bool `json:"syncing"` // 這一刻正在跑一輪嗎
|
||
CrashLooping bool `json:"crash_looping"` // 一直啟動失敗(≥3 次重試都失敗,見 crashLoopThreshold)
|
||
Restarts int `json:"restarts"` // 累計重試次數
|
||
LastError string `json:"last_error,omitempty"` // 最近一次啟動失敗的原因
|
||
Headline string `json:"headline"` // 同首頁大字,例如「同步引擎沒有在跑」
|
||
Detail string `json:"detail,omitempty"` // 同首頁小字
|
||
|
||
// LastSync=最近一輪「心跳」完成時間(RFC3339)。這是每一輪(預設每 5 秒)**不論那輪
|
||
// 有沒有事做都會寫**的欄位(見 collector/direct.go RunDirectOnce 尾端),所以它是心跳,
|
||
// 不是「上次同步到東西」的時間。
|
||
// LastActivityAt=最近一輪「真的有產出」(成功或失敗都算)的時間,同首頁「已整理 N 份」
|
||
// 那個時間戳(見 collector/sync_status.go CarryForwardActivity)。
|
||
//
|
||
// ⇒ pending=0 但 last_sync 是很久以前 ⇒ 不是「做完了」,是「心跳停了」;
|
||
// last_sync 很新但 alive=false ⇒ daemon 這一刻已死,last_sync 是死前最後一口氣。
|
||
LastSync string `json:"last_sync,omitempty"`
|
||
LastActivityAt string `json:"last_activity_at,omitempty"`
|
||
SecondsSinceLastSync *int64 `json:"seconds_since_last_sync,omitempty"` // generated_at 減 last_sync 的秒數;從沒同步過時省略(不假裝有數字)
|
||
}
|
||
|
||
// 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)
|
||
|
||
// t213 phase 3(Q2):現查現答同步引擎「現在還在不在跑」,直接讀 supervise.go
|
||
// 已經在維護的同一組憑據(供首頁 describeStatus() 用的那套),不重新發明判斷法。
|
||
_, headline, detail := describeStatus(sync)
|
||
alive := collectorAlive()
|
||
lastErr, restarts, looping := collectorFailure()
|
||
engine := engineDiagnostics{
|
||
Alive: alive,
|
||
Syncing: collectorSyncing(),
|
||
CrashLooping: looping,
|
||
Restarts: restarts,
|
||
// t213 phase 4(leo 08-08 追加):headline/detail 沿用首頁同一套 describeStatus(),
|
||
// 但 detail 在 !alive 時會把 collectorFailure() 的死因原文接進來(見 describeStatus
|
||
// 的「原因:」那幾行)——那句死因常常就是 supervisor 捕到的 stderr 末行,可能帶著
|
||
// 監看資料夾的本機絕對路徑(見下面 redactLocalPaths 的長註解)。這裡只遮蔽「要匯出
|
||
// 成檔案」的這一份,describeStatus() 本身/首頁托盤畫面完全不動。
|
||
Headline: redactLocalPaths(headline),
|
||
Detail: redactLocalPaths(detail),
|
||
}
|
||
engine.LastError = redactLocalPaths(engineLastErrorFor(alive, looping, lastErr))
|
||
|
||
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 的既有 fallback(api_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, engine, accounts)
|
||
}
|
||
|
||
// engineLastErrorFor 決定 LastError 要不要出現在診斷檔——只在「真的代表出事」時才附上
|
||
// (子行程不活著、或一直啟動失敗)。
|
||
//
|
||
// 🔴 supervisor 的 LastError 是「最近一行 stderr」,即使子行程健康,開機橫幅那類純資訊
|
||
// 也會被記進去(見 supervisor.go runOnce 的 stderr scanner,本身不判斷這行是不是真錯誤——
|
||
// 這是既有共用機制的既有行為,不在本次改動範圍);若不加這道判斷,alive=true 的健康快照裡
|
||
// 也會混進一句看起來像錯誤、實際只是啟動訊息的文字,誤導看診斷檔的人。
|
||
// 同首頁 describeStatus():只在「一直失敗」或「沒有在跑」兩種狀態才把這句話端上檯面。
|
||
func engineLastErrorFor(alive, crashLooping bool, lastErr string) string {
|
||
if !alive || crashLooping {
|
||
return lastErr
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// redactLocalPaths — t213 phase 4(leo 08-08 追加規則④)。
|
||
//
|
||
// 背景:`engine.detail`/`engine.last_error` 的內容有一部分來自 collectorFailure() 的
|
||
// 死因原文,那句話是 supervisor 捕到的 stderr **末行**(見 supervisor.go runOnce 的
|
||
// stderr scanner)——常見的情況是子行程剛啟動就掛,最後一行 stderr 停在
|
||
// direct.go RunDirect 印的開機橫幅:「collector direct daemon 啟動:監看 <資料夾絕對路徑>
|
||
// → <目的地>」,也可能是任何 os.PathError 形狀的訊息(如「open /Users/x/y: permission
|
||
// denied」)。兩種形狀都以本機絕對路徑開頭——這條路徑會洩漏使用者的目錄結構(含帳號名,
|
||
// 例如 /Users/<帳號>/... 或 C:\Users\<帳號>\...),跟既有的「失敗檔名只出 basename」
|
||
// (見 buildSkipped())是同一個理由,只是換了個欄位發作。
|
||
//
|
||
// 怎麼遮:只挖掉「路徑本體」,換成「…/<路徑最後一截>」——資料夾/檔名本身保留,
|
||
// 訊息的其餘文字原封不動。這樣「引擎沒在跑、以及為什麼」依然讀得懂(不能把資訊遮成啞巴),
|
||
// 只是看不出這是使用者電腦上的哪個帳號、哪層目錄。
|
||
//
|
||
// 只在這裡(診斷檔匯出的最後一哩)做,不改 direct.go/supervisor.go 的訊息本身,也不改
|
||
// describeStatus()/collectorFailure():那兩支同時也是首頁托盤畫面在用的同一組憑據,
|
||
// 動了會連首頁畫面一起變——超出本次「只動診斷檔這一端」的範圍(首頁要不要一起改,
|
||
// 留給總管/leo 另外決定,見交接紀錄)。
|
||
func redactLocalPaths(s string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
r := []rune(s)
|
||
n := len(r)
|
||
var b strings.Builder
|
||
for i := 0; i < n; {
|
||
// http(s) URL 先放行,原文照抄——避免其中的 `/`(如 .../webhooks/named/...)
|
||
// 被下面的「本機路徑」判斷誤吃(URL 不是本機目錄結構,不在遮蔽範圍內)。
|
||
if j, ok := urlSpanAt(r, i); ok {
|
||
b.WriteString(string(r[i:j]))
|
||
i = j
|
||
continue
|
||
}
|
||
if plen := absPathPrefixLen(r, i); plen > 0 {
|
||
start := i
|
||
j := i + plen
|
||
for j < n && !isPathBoundary(r[j]) {
|
||
j++
|
||
}
|
||
b.WriteString("…/")
|
||
b.WriteString(lastPathSegment(string(r[start:j])))
|
||
i = j
|
||
continue
|
||
}
|
||
b.WriteRune(r[i])
|
||
i++
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// urlSpanAt 偵測 i 處是不是 http(s) URL 的起點(必須在字詞邊界上,避免咬到中間),
|
||
// 回傳 URL 一路唸到下一個 URL 邊界字元為止的結尾位置。
|
||
//
|
||
// 🔴 這裡不能直接用 isPathBoundary 當終止點——URL 裡的 `://` scheme 分隔符與
|
||
// `:port` 都合法含有冒號,若拿冒號當終止點,`https://` 掃到第一個 `:` 就會被腰斬成
|
||
// 「https」,剩下的 `//host/path` 又會被 absPathPrefixLen 誤判成本機路徑(因為冒號
|
||
// 本身是 isPathBoundary 認得的邊界字元,讓緊接在後面的 `/` 通過起點檢查)——
|
||
// 這正是這支函式存在的理由,用專屬的終止點集合(isPathBoundary 拿掉冒號)。
|
||
func urlSpanAt(r []rune, i int) (int, bool) {
|
||
if i > 0 && !isPathBoundary(r[i-1]) {
|
||
return 0, false
|
||
}
|
||
rest := string(r[i:min(i+8, len(r))])
|
||
if !strings.HasPrefix(rest, "http://") && !strings.HasPrefix(rest, "https://") {
|
||
return 0, false
|
||
}
|
||
j := i
|
||
for j < len(r) && !isURLBoundary(r[j]) {
|
||
j++
|
||
}
|
||
return j, true
|
||
}
|
||
|
||
// isURLBoundary=isPathBoundary 拿掉冒號(URL 合法含冒號,見上面 urlSpanAt 的註解)。
|
||
func isURLBoundary(r rune) bool {
|
||
if r == ':' {
|
||
return false
|
||
}
|
||
return isPathBoundary(r)
|
||
}
|
||
|
||
// absPathPrefixLen 判斷 i 是不是一個本機絕對路徑的起點,回傳「路徑根部」本身的字元數
|
||
// (Unix `/` 算 1 個;Windows 磁碟代號 `C:\`/`C:/` 算 3 個)。只在「前一個字元是邊界
|
||
// 字元(或 i==0)」時才算數——避免把一般文字裡湊巧出現的字元誤判成路徑起點。
|
||
func absPathPrefixLen(r []rune, i int) int {
|
||
if i > 0 && !isPathBoundary(r[i-1]) {
|
||
return 0
|
||
}
|
||
if r[i] == '/' {
|
||
return 1
|
||
}
|
||
if i+2 < len(r) && isASCIILetter(r[i]) && r[i+1] == ':' && (r[i+2] == '\\' || r[i+2] == '/') {
|
||
return 3
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func isASCIILetter(r rune) bool {
|
||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||
}
|
||
|
||
// isPathBoundary=這個字元不可能是路徑本體的一部分,可以拿來當「路徑到這裡結束」的判準
|
||
// (空白、中西文標點、箭頭)。本檔目前已知的兩種訊息形狀都靠它正確截斷:
|
||
// 「監看 A、B → url」用「、」「→」收尾;`os.PathError` 的「path: 原因」用「:」收尾。
|
||
// 刻意不把純空白以外的一般文字字元(含資料夾名裡常見的空格)當終止點以外的東西處理——
|
||
// 資料夾名稱裡的空格會讓比對在空格處提早停下,不會吃到後面不相干的文字,這是刻意的保守
|
||
// 選擇:寧可少遮一點(漏出資料夾名稱片段),也不要多遮(把後面的說明文字一起吃掉)。
|
||
func isPathBoundary(r rune) bool {
|
||
switch r {
|
||
case ' ', '\t', '\n', '\r',
|
||
'、', '(', ')', '(', ')',
|
||
',', ';', ',', ';', ':', '→',
|
||
'"', '\'', '<', '>':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// lastPathSegment 取路徑最後一截(跨 `/`、`\` 兩種分隔符,不假設執行平台),
|
||
// 拿不到任何一截(例如整條路徑只有根目錄)時回一個看得懂的佔位字,不留空字串。
|
||
func lastPathSegment(p string) string {
|
||
p = strings.TrimRight(p, `/\`)
|
||
if idx := strings.LastIndexAny(p, `/\`); idx >= 0 {
|
||
p = p[idx+1:]
|
||
}
|
||
if p == "" {
|
||
return "本機路徑"
|
||
}
|
||
return p
|
||
}
|
||
|
||
// mergeDiagnostics 純函式:把已經各自拿到的本機/雲端資料組成最終輸出形狀,不做任何 IO。
|
||
//
|
||
// leo 08-08 規則對照:
|
||
// ① 同首頁數字——Progress/FailureBreakdown 原樣接住 sync 裡 t210 已算好的值,不重算。
|
||
// ② 分類名稱只認得 collector/progress.go 的 ClassifyFailure——這裡原樣照抄
|
||
// sync.FailureBreakdown,本函式不比對/不認得任何一個分類字串。
|
||
// ③ 失敗檔名只出 basename——skipped.Files 沿用 buildSkipped() 既有輸出(本來就是
|
||
// filepath.Base()+白話標籤),本函式不重新處理路徑。
|
||
func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, update UpdateInfo, engine engineDiagnostics, accounts []accountDiagnostics) exportedDiagnostics {
|
||
now := time.Now().UTC()
|
||
|
||
// engine.Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail 是呼叫端
|
||
// 現查現答傳進來的(IO/即時行程狀態,見 buildDiagnosticsPayload);這裡只補上
|
||
// 從 status.json 本來就有、原樣接住的兩個時間戳,以及純算術算出的落後秒數。
|
||
engine.LastSync = sync.LastSync
|
||
engine.LastActivityAt = sync.LastActivityAt
|
||
if sync.LastSync != "" {
|
||
if t, err := time.Parse(time.RFC3339, sync.LastSync); err == nil {
|
||
secs := int64(now.Sub(t).Seconds())
|
||
engine.SecondsSinceLastSync = &secs
|
||
}
|
||
}
|
||
|
||
local := localDiagnostics{
|
||
DaemonVersion: daemonVersion,
|
||
UpdateCheck: update,
|
||
Engine: engine,
|
||
Progress: sync.Progress,
|
||
FailureBreakdown: sync.FailureBreakdown,
|
||
}
|
||
if skipped != nil {
|
||
local.SkippedSample = skipped.Files
|
||
local.SkippedMore = skipped.More
|
||
}
|
||
return exportedDiagnostics{
|
||
GeneratedAt: now.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
|
||
}
|