完善通知重连和更新失败重试

This commit is contained in:
Scout开发者 2026-08-31 15:57:05 +08:00
parent f1546b9efc
commit 21a0275e2f
3 changed files with 152 additions and 32 deletions

View File

@ -35,22 +35,23 @@ export function useHome() {
const message = ref('')
const error = ref('')
let eventSource = null
let refreshTimer = null
let refreshGeneration = 0
const isLoggedIn = computed(() => Boolean(user.value && session.value))
const canUpload = computed(() => user.value?.role === 'developer' || user.value?.role === 'admin')
const isAdmin = computed(() => user.value?.role === 'admin')
const isScoutPackage = computed(() => uploadForm.value.file?.name.toLowerCase().endsWith('.scout'))
const platformOptions = ['windows', 'macos', 'linux', 'android', 'any']
const architectureOptions = computed(() => {
const platform = filters.value.platform || uploadForm.value.platform
const options = {
windows: ['amd64', 'arm64', '386'],
macos: ['amd64', 'arm64'],
linux: ['amd64', 'arm64', '386', 'armv7'],
android: ['arm64', 'armv7', '386'],
}
return options[platform] || ['amd64', 'arm64', '386', 'armv7', 'universal']
})
const architectureOptionsByPlatform = {
windows: ['amd64', 'arm64', '386'],
macos: ['amd64', 'arm64'],
linux: ['amd64', 'arm64', '386', 'armv7'],
android: ['arm64', 'armv7', '386'],
}
const universalArchitectureOptions = ['amd64', 'arm64', '386', 'armv7', 'universal']
const filterArchitectureOptions = computed(() => architectureOptionsByPlatform[filters.value.platform] || universalArchitectureOptions)
const uploadArchitectureOptions = computed(() => architectureOptionsByPlatform[uploadForm.value.platform] || universalArchitectureOptions)
const channelOptions = ['stable', 'beta', 'dev']
function roleText(role) {
@ -110,6 +111,41 @@ export function useHome() {
}
}
async function refreshPackages() {
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
const generation = ++refreshGeneration
await loadPackages()
if (generation === refreshGeneration) {
startNotifications()
}
}
function scheduleRefresh() {
if (refreshTimer) {
clearTimeout(refreshTimer)
}
refreshTimer = setTimeout(() => {
refreshTimer = null
void refreshPackages()
}, 180)
}
function normalizeFilterArchitecture() {
if (filters.value.architecture && !filterArchitectureOptions.value.includes(filters.value.architecture)) {
filters.value.architecture = ''
}
scheduleRefresh()
}
function normalizeUploadArchitecture() {
if (uploadForm.value.architecture && !uploadArchitectureOptions.value.includes(uploadForm.value.architecture)) {
uploadForm.value.architecture = ''
}
}
async function selectPackage(item) {
selectedPackage.value = item
error.value = ''
@ -168,7 +204,7 @@ export function useHome() {
uploadForm.value.file = null
const fileInput = document.querySelector('#package-file')
if (fileInput) fileInput.value = ''
await loadPackages()
await refreshPackages()
} catch (reason) {
error.value = reason.message
} finally {
@ -321,6 +357,10 @@ export function useHome() {
function stopNotifications() {
eventSource?.close()
eventSource = null
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
return {
@ -348,10 +388,15 @@ export function useHome() {
isAdmin,
isScoutPackage,
platformOptions,
architectureOptions,
filterArchitectureOptions,
uploadArchitectureOptions,
channelOptions,
roleText,
loadPackages,
refreshPackages,
scheduleRefresh,
normalizeFilterArchitecture,
normalizeUploadArchitecture,
selectPackage,
onFileChange,
uploadPackage,
@ -367,4 +412,3 @@ export function useHome() {
stopNotifications,
}
}

View File

@ -1,5 +1,5 @@
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { onMounted, onUnmounted, watch } from 'vue'
import { useHome } from './Home.js'
import './Home.css'
@ -27,10 +27,15 @@ const {
isAdmin,
isScoutPackage,
platformOptions,
architectureOptions,
filterArchitectureOptions,
uploadArchitectureOptions,
channelOptions,
roleText,
loadPackages,
refreshPackages,
scheduleRefresh,
normalizeFilterArchitecture,
normalizeUploadArchitecture,
selectPackage,
onFileChange,
uploadPackage,
@ -48,10 +53,10 @@ const {
onMounted(async () => {
await loadMe()
await loadPackages()
startNotifications()
await refreshPackages()
})
watch(filters, scheduleRefresh, { deep: true })
onUnmounted(stopNotifications)
</script>
@ -80,16 +85,16 @@ onUnmounted(stopNotifications)
</label>
<label>
<span>平台筛选</span>
<select v-model="filters.platform">
<select v-model="filters.platform" @change="normalizeFilterArchitecture">
<option value="">全部平台</option>
<option v-for="item in platformOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>
<span>架构筛选</span>
<select v-model="filters.architecture">
<select v-model="filters.architecture" @change="normalizeFilterArchitecture">
<option value="">全部架构</option>
<option v-for="item in architectureOptions" :key="item" :value="item">{{ item }}</option>
<option v-for="item in filterArchitectureOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>
@ -103,7 +108,7 @@ onUnmounted(stopNotifications)
<span>当前版本</span>
<input v-model="filters.currentVersion" type="text" placeholder="例如:1.0.0" @keyup.enter="loadPackages" />
</label>
<button class="secondary-button" type="button" @click="loadPackages">刷新包列表</button>
<button class="secondary-button" type="button" @click="refreshPackages">刷新包列表</button>
<div v-if="isLoggedIn" class="user-summary">
<span>{{ user.name }}({{ roleText(user.role) }})</span>
<button class="text-button" type="button" @click="logout">退出</button>
@ -252,16 +257,16 @@ onUnmounted(stopNotifications)
</label>
<label>
<span>平台</span>
<select v-model="uploadForm.platform" :disabled="isScoutPackage">
<select v-model="uploadForm.platform" :disabled="isScoutPackage" @change="normalizeUploadArchitecture">
<option value="">请选择平台</option>
<option v-for="item in platformOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>
<span>架构</span>
<select v-model="uploadForm.architecture" :disabled="isScoutPackage">
<select v-model="uploadForm.architecture" :disabled="isScoutPackage" @change="normalizeUploadArchitecture">
<option value="">请选择架构</option>
<option v-for="item in architectureOptions" :key="item" :value="item">{{ item }}</option>
<option v-for="item in uploadArchitectureOptions" :key="item" :value="item">{{ item }}</option>
</select>
</label>
<label>

View File

@ -177,6 +177,68 @@ func TestHealthyCleanupUsesConfiguredRetention(t *testing.T) {
}
}
func TestPrepareCleansFailedStagingAndCanRetry(t *testing.T) {
api := newTestClient(t)
invalidManifest := scoutpkg.Manifest{
SchemaVersion: 1,
Name: "demo-core",
Version: "1.1.0",
Channel: "stable",
PackageType: "full",
Targets: []scoutpkg.Target{{
Platform: "linux",
Architecture: "amd64",
Path: "targets/linux/amd64",
Entrypoint: "targets/linux/amd64/missing",
}},
}
if _, err := api.Upload(context.Background(), client.UploadRequest{
FileName: "invalid.scout",
File: bytes.NewReader(createScoutArchive(t, invalidManifest, map[string][]byte{
"targets/linux/amd64/payload": []byte("payload"),
})),
}); err != nil {
t.Fatal(err)
}
root := t.TempDir()
manager, err := New(Config{
Client: api,
PackageName: "demo-core",
RootDir: root,
Platform: "linux",
Architecture: "amd64",
HealthTimeout: 10 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
if _, err := manager.Prepare(context.Background(), ""); err == nil {
t.Fatal("入口不存在的更新包应该准备失败")
}
staging, err := filepath.Glob(filepath.Join(root, "releases", ".staging-*"))
if err != nil {
t.Fatal(err)
}
if len(staging) != 0 {
t.Fatalf("失败后不应保留暂存目录: %v", staging)
}
if _, err := api.Upload(context.Background(), client.UploadRequest{
FileName: "valid.scout",
File: bytes.NewReader(createScoutPackage(t, "1.2.0")),
}); err != nil {
t.Fatal(err)
}
prepared, err := manager.Prepare(context.Background(), "")
if err != nil {
t.Fatalf("清理失败暂存目录后应允许重试: %v", err)
}
if prepared.Version != "1.2.0" {
t.Fatalf("重试准备的版本错误: %s", prepared.Version)
}
}
func newTestClient(t *testing.T) *client.Client {
t.Helper()
store, err := registry.New(t.TempDir())
@ -194,8 +256,6 @@ func newTestClient(t *testing.T) *client.Client {
func createScoutPackage(t *testing.T, version string) []byte {
t.Helper()
buffer := bytes.NewBuffer(nil)
writer := zip.NewWriter(buffer)
manifest := scoutpkg.Manifest{
SchemaVersion: 1,
Name: "demo-core",
@ -209,6 +269,15 @@ func createScoutPackage(t *testing.T, version string) []byte {
Entrypoint: "targets/linux/amd64/demo",
}},
}
return createScoutArchive(t, manifest, map[string][]byte{
"targets/linux/amd64/demo": []byte("new binary"),
})
}
func createScoutArchive(t *testing.T, manifest scoutpkg.Manifest, files map[string][]byte) []byte {
t.Helper()
buffer := bytes.NewBuffer(nil)
writer := zip.NewWriter(buffer)
manifestData, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
@ -220,12 +289,14 @@ func createScoutPackage(t *testing.T, version string) []byte {
if _, err := entry.Write(manifestData); err != nil {
t.Fatal(err)
}
entry, err = writer.Create("targets/linux/amd64/demo")
if err != nil {
t.Fatal(err)
}
if _, err := entry.Write([]byte("new binary")); err != nil {
t.Fatal(err)
for name, content := range files {
entry, err := writer.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := entry.Write(content); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)