diff --git a/cloud_latest.go b/cloud_latest.go new file mode 100644 index 0000000..5f72e84 --- /dev/null +++ b/cloud_latest.go @@ -0,0 +1,105 @@ +// cloud_latest.go — t215(2026-08-08)「每個知識庫要不要更新」的全域比較基準。 +// +// leo 原話:「在每個知識庫上顯示是否要更新,如果要,加開啓 install 頁的連結」。 +// 一個使用者可能連著不只一個雲端知識庫(帳號),每個帳號各自的雲端版本可能不同步—— +// 之前只有 t150 那套「小幫手自己」的更新提示,雲端側完全沒有對應的畫面。 +// +// 🔴 判準**必須跟 portal 設定頁那張版本卡一致**(不能自己另立一套,見驗收要求): +// `console-ui/public/portal/index.html` 的 `loadVersion()` 拿自己的 `bundle_version` +// (cypher `/health`)比對 `install.arcrun.dev/api/latest` 的 `release` 欄位, +// 用逐段整數比較(不是字串比較),非 semver 格式一律視為落後。 +// 這裡原樣照抄同一個比法與同一個資料源,**不是**沿用 cloud_version.go 的 +// `cloudVersionStale`——那支比的是 `minCloudRelease`(協定相容底線,「太舊會壞掉」), +// 跟這裡要回答的「有沒有更新版可以裝」是兩個不同的問題,兩把尺不能混用, +// 否則同一個知識庫會在小幫手與 portal 兩處得到相反答案。 +package collector + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "time" +) + +const installLatestURL = "https://install.arcrun.dev/api/latest" + +// latestCacheTTL:install.arcrun.dev 自己在 CF edge 快取 5 分鐘(landing/worker.js +// 同一顆端點的既有用法),daemon 端沒必要比它更頻繁去打;同步輪詢間隔常常只有十幾秒, +// 若不節流,每個帳號每輪都會外打一次全域端點=不必要的高頻請求。30 分鐘一輪已經足夠 +// 讓使用者在「新版剛發佈」後半小時內看到提示。 +const latestCacheTTL = 30 * time.Minute + +var ( + latestMu sync.Mutex + latestCached string + latestCachedOK bool + latestFetched time.Time +) + +// fetchLatestCloudReleaseRaw 可在測試中替換為 stub(同 fetchCloudVersion 慣例)。 +var fetchLatestCloudReleaseRaw = func() (string, bool) { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(installLatestURL) + if err != nil { + return "", false + } + defer resp.Body.Close() + var payload struct { + Release string `json:"release"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&payload); err != nil { + return "", false + } + if strings.TrimSpace(payload.Release) == "" { + return "", false + } + return payload.Release, true +} + +// FetchLatestCloudRelease 回傳目前已知的「雲端最新版」,內建節流(見 latestCacheTTL)。 +// 節流窗內回快取值(含失敗快取=ok=false);窗口過了才真的重打一次。 +// 這是**全域單一值**(不分帳號)——所有知識庫比的是同一個「目前最新版是什麼」。 +func FetchLatestCloudRelease() (release string, ok bool) { + latestMu.Lock() + if time.Since(latestFetched) < latestCacheTTL { + release, ok = latestCached, latestCachedOK + latestMu.Unlock() + return + } + latestMu.Unlock() + + release, ok = fetchLatestCloudReleaseRaw() + + latestMu.Lock() + latestCached, latestCachedOK, latestFetched = release, ok, time.Now() + latestMu.Unlock() + return +} + +// CloudUpdateStatus 是「這個知識庫要不要更新」的判定結果。 +// Known=false 時前端要照實講「查不到」,不能當成「已是最新」—— +// 靜默把「不知道」呈現成「一切正常」正是 cloud_version.go 開頭記過的那個坑。 +type CloudUpdateStatus struct { + Known bool // 兩邊版本都拿得到才能下判斷 + NeedsUpdate bool // Known 且落後 + Mine string // 這個帳號目前的 bundle_version(可能是空字串或舊格式) + Latest string // 已知的最新版(可能是空字串=暫時查不到) +} + +// EvalCloudUpdate 比較單一帳號的 bundle_version 與全域最新版。 +// 與 portal 版本卡 loadVersion() 的 cmpSemver 同一套邏輯: +// - mine 拿不到、或 latest 拿不到 → Known=false(誠實說「查不到」) +// - mine 不是 semver 格式(老實例的 YYYY-MM-DD+sha)→ 一律當落後 +// (新版才會寫 semver 進來,portal 端註解原話同此) +// - 兩邊都是 semver → 逐段整數比較,mine < latest 才算落後 +func EvalCloudUpdate(mine string, mineOK bool, latest string, latestOK bool) CloudUpdateStatus { + mine = strings.TrimSpace(mine) + latest = strings.TrimSpace(latest) + if !mineOK || mine == "" || !latestOK || latest == "" { + return CloudUpdateStatus{Known: false, Mine: mine, Latest: latest} + } + behind := !isSemverLike(mine) || compareSemver(mine, latest) < 0 + return CloudUpdateStatus{Known: true, NeedsUpdate: behind, Mine: mine, Latest: latest} +} diff --git a/cloud_latest_test.go b/cloud_latest_test.go new file mode 100644 index 0000000..4a9160d --- /dev/null +++ b/cloud_latest_test.go @@ -0,0 +1,84 @@ +// cloud_latest_test.go — t215 單元測試:EvalCloudUpdate 的判準要跟 portal 版本卡一致。 +package collector + +import "testing" + +func TestEvalCloudUpdate(t *testing.T) { + cases := []struct { + name string + mine string + mineOK bool + latest string + latestOK bool + wantKnown bool + wantUpdate bool + }{ + { + name: "兩邊都拿得到、mine 落後", mine: "1.4.1", mineOK: true, latest: "1.4.2", latestOK: true, + wantKnown: true, wantUpdate: true, + }, + { + name: "兩邊都拿得到、已是最新", mine: "1.4.2", mineOK: true, latest: "1.4.2", latestOK: true, + wantKnown: true, wantUpdate: false, + }, + { + // 字串比較會誤判 "1.10.0" < "1.9.0";逐段整數比較才對(同 t103 迴歸守衛)。 + name: "1.10.0 比 1.9.0 新,不該判落後", mine: "1.10.0", mineOK: true, latest: "1.9.0", latestOK: true, + wantKnown: true, wantUpdate: false, + }, + { + // 老格式(YYYY-MM-DD+sha)——portal 版本卡註解原話:「這種情況一律當成落後」。 + name: "老格式版本一律當落後", mine: "2026-07-31+8e83589", mineOK: true, latest: "1.4.2", latestOK: true, + wantKnown: true, wantUpdate: true, + }, + { + name: "連不上這個知識庫(mineOK=false)→ 查不到,不能裝沒事", mine: "", mineOK: false, latest: "1.4.2", latestOK: true, + wantKnown: false, wantUpdate: false, + }, + { + name: "查得到 mine 但暫時查不到最新版 → 查不到,不是已最新", mine: "1.4.2", mineOK: true, latest: "", latestOK: false, + wantKnown: false, wantUpdate: false, + }, + { + name: "/health 可達但 bundle_version 空字串(老實例)→ 查不到", mine: "", mineOK: true, latest: "1.4.2", latestOK: true, + wantKnown: false, wantUpdate: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := EvalCloudUpdate(tc.mine, tc.mineOK, tc.latest, tc.latestOK) + if got.Known != tc.wantKnown { + t.Errorf("Known = %v,want %v", got.Known, tc.wantKnown) + } + if got.NeedsUpdate != tc.wantUpdate { + t.Errorf("NeedsUpdate = %v,want %v", got.NeedsUpdate, tc.wantUpdate) + } + }) + } +} + +// TestFetchLatestCloudReleaseThrottle 驗證節流:窗口內第二次呼叫不重打 fetchLatestCloudReleaseRaw。 +func TestFetchLatestCloudReleaseThrottle(t *testing.T) { + calls := 0 + orig := fetchLatestCloudReleaseRaw + defer func() { + fetchLatestCloudReleaseRaw = orig + latestMu.Lock() + latestCached, latestCachedOK, latestFetched = "", false, latestFetched.Add(-2*latestCacheTTL) + latestMu.Unlock() + }() + fetchLatestCloudReleaseRaw = func() (string, bool) { calls++; return "1.4.2", true } + // 強制第一次一定重打(避開其他測試留下的快取)。 + latestMu.Lock() + latestFetched = latestFetched.Add(-2 * latestCacheTTL) + latestMu.Unlock() + + r1, ok1 := FetchLatestCloudRelease() + r2, ok2 := FetchLatestCloudRelease() + if calls != 1 { + t.Errorf("節流窗口內第二次呼叫不該重打,calls = %d", calls) + } + if r1 != "1.4.2" || !ok1 || r2 != "1.4.2" || !ok2 { + t.Errorf("兩次結果應相同,got (%q,%v) (%q,%v)", r1, ok1, r2, ok2) + } +} diff --git a/cloud_version_test.go b/cloud_version_test.go index 6d30922..e19ac67 100644 --- a/cloud_version_test.go +++ b/cloud_version_test.go @@ -11,6 +11,9 @@ import ( // TestCloudVersionStale 直接測 cloudVersionStale 邏輯,不受此 stub 影響。 func TestMain(m *testing.M) { fetchCloudVersion = func(string) (string, bool) { return "", false } + // t215:同一個理由——避免 EvalCloudUpdate 相關測試因為真的打了 + // install.arcrun.dev 而變成看網路臉色的測試。 + fetchLatestCloudReleaseRaw = func() (string, bool) { return "", false } os.Exit(m.Run()) } diff --git a/cmd/arcrun-app/app.go b/cmd/arcrun-app/app.go index d452e43..3612711 100644 --- a/cmd/arcrun-app/app.go +++ b/cmd/arcrun-app/app.go @@ -96,6 +96,11 @@ type syncStatus struct { // 不重新定義結構,避免兩邊的欄位定義漂移。 Progress collector.SyncProgress `json:"progress"` FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"` + // t215(2026-08-08):per-account 雲端版本狀態——collector.AccountSyncStatus 已經是 + // GetState 要的形狀(含 t215 新欄位 CloudUpdateKnown/CloudUpdateStale/CloudLatest), + // 直接原樣接住,不重新定義一份會漂移的結構。key = instanceHostOf(cypher_url) + // (與 UIAccount.Host 同一套算法,見 shortHost)。 + AccountDetails map[string]collector.AccountSyncStatus `json:"account_details,omitempty"` } type skippedDoc struct { @@ -219,6 +224,15 @@ type UIAccount struct { Name string `json:"name"` Host string `json:"host"` Folders []UIFolder `json:"folders"` + // t215(2026-08-08,leo:「在每個知識庫上顯示是否要更新,如果要,加開啓 install 頁的 + // 連結」)——一個使用者可能連著不只一個知識庫,之前只有小幫手自己的版本會提示更新, + // 每個知識庫各自的雲端版本完全沒有畫面。判準與 portal 版本卡同一套 + // (collector.EvalCloudUpdate,不是 t103 的相容底線),這裡只翻成人話,不重新判斷。 + CloudVerKnown bool `json:"cloudVerKnown"` // false=查不到(連不上/還沒查過),前端要老實說「查不到」 + CloudVerStale bool `json:"cloudVerStale"` // true=有新版可更新 + CloudVerMine string `json:"cloudVerMine,omitempty"` // 這個知識庫目前的版本(可能連 Known=false 時也有值) + CloudVerLatest string `json:"cloudVerLatest,omitempty"` // 已知的最新版 + Email string `json:"email,omitempty"` // 供「前往安裝頁更新」預填帳號(同 portal 版本卡的做法) } type UIState struct { Version string `json:"version"` @@ -415,11 +429,20 @@ func (a *App) GetState() UIState { } } + sync := loadSyncStatus() for i, acc := range cfg.Accounts { - ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL)} + ui := UIAccount{Name: accountName(acc), Host: shortHost(acc.CypherURL), Email: acc.Email} for _, f := range acc.WatchFolders { ui.Folders = append(ui.Folders, UIFolder{Path: f, AccIdx: i}) } + // t215:per-account 雲端版本狀態——key 與 Host 同一套算法(shortHost), + // 對應 collector 寫入 status.json 時用的 instanceHostOf(兩者對一般 https URL 同值)。 + if accSt, ok := sync.AccountDetails[ui.Host]; ok { + ui.CloudVerKnown = accSt.CloudUpdateKnown + ui.CloudVerStale = accSt.CloudUpdateStale + ui.CloudVerMine = accSt.CloudVersion + ui.CloudVerLatest = accSt.CloudLatest + } st.Accounts = append(st.Accounts, ui) } @@ -431,7 +454,6 @@ func (a *App) GetState() UIState { st.GeminiKey = "••••••••" } - sync := loadSyncStatus() st.Syncing, st.StatusBig, st.StatusSub = describeStatus(sync) st.Steps = buildSteps(sync, st.Syncing) st.Skipped = buildSkipped(sync) diff --git a/cmd/arcrun-app/frontend/src/main.js b/cmd/arcrun-app/frontend/src/main.js index b8a9a8c..8f83e31 100644 --- a/cmd/arcrun-app/frontend/src/main.js +++ b/cmd/arcrun-app/frontend/src/main.js @@ -37,6 +37,16 @@ $('overlay').addEventListener('click', (e) => { if (e.target.id === 'overlay') c document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet(); }); // ── 側邊欄:每個知識庫一項(leo:「每個帳號有獨立的一個頁面」)── +// +// t215(2026-08-08,leo:「在每個知識庫上顯示是否要更新」):落後的庫名旁加一顆 +// 警示點,逛清單時不用點進每個庫就能一眼看出哪個落後(完整說明+更新按鈕在 +// 首頁 cardKbVersions 與各庫頁 kbVersionLine)。 +// +// 🔴 頂層 status.md 08-08 深夜記過一個**待 leo confirm、尚未定案**的提案: +// 「單一更新入口(版本白癡化)」——把小幫手自我更新與雲端知識庫更新合併成一顆按鈕。 +// 那個提案沒有否定「每庫獨立列出落後狀態」這件事本身(它本來就要「點進去才看細節」), +// 只是問「總覽要不要合併」。這裡先實作 leo 這次明確要的「每庫看得到+連得到」, +// 之後若那個提案 confirm,是在這層之上疊總覽 pill,不是重做這裡。 function renderNav() { const accs = (state && state.accounts) || []; $('nav').innerHTML = ` @@ -45,6 +55,7 @@ function renderNav() { ${accs.map((a, i) => `
`).join('')}