magpie-gemini-web 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,625 +0,0 @@
1
- diff --git a/internal/app/admin.go b/internal/app/admin.go
2
- index 2f3de19..7f84cd5 100644
3
- --- a/internal/app/admin.go
4
- +++ b/internal/app/admin.go
5
- @@ -8,7 +8,6 @@ import (
6
- "net/http"
7
- "os"
8
- "path/filepath"
9
- - "runtime/debug"
10
- "sort"
11
- "strings"
12
- "sync"
13
- @@ -1390,13 +1389,10 @@ func (registry *requestRegistry) publish(event api.AdminEvent) {
14
- registry.mu.Unlock()
15
- }
16
-
17
- -func buildVersion() string {
18
- - info, ok := debug.ReadBuildInfo()
19
- - if !ok || info.Main.Version == "" {
20
- - return "(devel)"
21
- - }
22
- - return info.Main.Version
23
- -}
24
- +// Set by the reproducible Magpie release build.
25
- +var managedVersion = "(devel)"
26
- +
27
- +func buildVersion() string { return managedVersion }
28
-
29
- func runtimeConfigDTO(cfg config.Config) api.RuntimeConfig {
30
- return api.RuntimeConfig{
31
- diff --git a/internal/app/update.go b/internal/app/update.go
32
- index 2b22ebe..f5dd6c3 100644
33
- --- a/internal/app/update.go
34
- +++ b/internal/app/update.go
35
- @@ -1,300 +1,45 @@
36
- package app
37
-
38
- import (
39
- - "archive/tar"
40
- - "archive/zip"
41
- - "bytes"
42
- - "compress/gzip"
43
- "context"
44
- - "crypto/sha256"
45
- - "encoding/hex"
46
- - "encoding/json"
47
- - "errors"
48
- - "fmt"
49
- - "io"
50
- "net/http"
51
- - "net/url"
52
- "os"
53
- "os/exec"
54
- - "path"
55
- "runtime"
56
- "slices"
57
- - "strings"
58
- - "sync"
59
- "syscall"
60
- - "time"
61
- -
62
- - "github.com/minio/selfupdate"
63
- - "golang.org/x/mod/semver"
64
-
65
- "github.com/Mag1cFall/AIStudio2API/internal/api"
66
- )
67
-
68
- -// releaseAPI 为 GitHub 最新发行版本接口
69
- -const releaseAPI = "https://api.github.com/repos/Mag1cFall/AIStudio2API/releases/latest"
70
- -
71
- -// releaseCheckInterval 为最新发行版本的缓存时长
72
- -const releaseCheckInterval = time.Hour
73
- -
74
- -// githubRelease 为 GitHub 发行版本接口中用到的字段
75
- -type githubRelease struct {
76
- - TagName string `json:"tag_name"`
77
- - Body string `json:"body"`
78
- - HTMLURL string `json:"html_url"`
79
- - PublishedAt time.Time `json:"published_at"`
80
- - Assets []githubAsset `json:"assets"`
81
- -}
82
- -
83
- -// githubAsset 为发行版本中的一个文件
84
- -type githubAsset struct {
85
- - Name string `json:"name"`
86
- - URL string `json:"browser_download_url"`
87
- - Digest string `json:"digest"`
88
- -}
89
- -
90
- -// updateConflictError 表示当前状态不能安装更新
91
- -type updateConflictError string
92
- -
93
- -func (err updateConflictError) Error() string { return string(err) }
94
- -
95
- -// HTTPStatus 返回更新冲突的 HTTP 状态码
96
- -func (updateConflictError) HTTPStatus() int { return http.StatusConflict }
97
- -
98
- -// selfUpdater 读取最新发行版本并替换当前可执行文件
99
- +// Magpie owns backend upgrades. This build has no release downloader or
100
- +// executable replacement path, regardless of platform or environment.
101
- type selfUpdater struct {
102
- - version string
103
- - mode string
104
- - releaseURL string
105
- - executable string
106
- - proxy func() string
107
- - log func(level, message string)
108
- - restart func()
109
- - mu sync.Mutex
110
- - release *githubRelease
111
- - checked time.Time
112
- - checkErr error
113
- - updating bool
114
- + version string
115
- }
116
-
117
- -// newSelfUpdater 按构建版本与运行环境创建替换 executable 的更新器
118
- -func newSelfUpdater(version, executable string, proxy func() string, log func(level, message string), restart func()) *selfUpdater {
119
- - return &selfUpdater{
120
- - version: version, mode: updateMode(version), releaseURL: releaseAPI, executable: executable,
121
- - proxy: proxy, log: log, restart: restart,
122
- - }
123
- +func newSelfUpdater(version, _ string, _ func() string, _ func(string, string), _ func()) *selfUpdater {
124
- + return &selfUpdater{version: version}
125
- }
126
-
127
- -// updateMode 返回更新方式:binary 可原地替换,container 随镜像更新,development 为源码构建
128
- -func updateMode(version string) string {
129
- - if !semver.IsValid(version) || semver.Prerelease(version) != "" || semver.Build(version) != "" {
130
- - return "development"
131
- - }
132
- - for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} {
133
- - if _, err := os.Stat(marker); err == nil {
134
- - return "container"
135
- - }
136
- - }
137
- - return "binary"
138
- +func (u *selfUpdater) UpdateStatus(_ context.Context, _ bool) (api.UpdateStatus, error) {
139
- + return api.UpdateStatus{Current: u.version, Mode: "managed"}, nil
140
- }
141
-
142
- -// UpdateStatus 返回当前版本与最新发行版本,refresh 为 true 或缓存过期时重新读取
143
- -func (u *selfUpdater) UpdateStatus(ctx context.Context, refresh bool) (api.UpdateStatus, error) {
144
- - u.mu.Lock()
145
- - stale := refresh || time.Since(u.checked) >= releaseCheckInterval
146
- - u.mu.Unlock()
147
- - if stale {
148
- - release, err := u.fetchRelease(ctx)
149
- - if ctx.Err() != nil {
150
- - return api.UpdateStatus{}, ctx.Err()
151
- - }
152
- - u.mu.Lock()
153
- - u.checked, u.checkErr = time.Now(), err
154
- - if err == nil {
155
- - u.release = release
156
- - }
157
- - u.mu.Unlock()
158
- - }
159
- - u.mu.Lock()
160
- - defer u.mu.Unlock()
161
- - return u.statusLocked(), nil
162
- -}
163
- +type managedUpdateDisabledError struct{}
164
-
165
- -// ApplyUpdate 下载最新发行包,校验摘要后替换当前可执行文件并重启管理进程
166
- -func (u *selfUpdater) ApplyUpdate(ctx context.Context) (api.UpdateStatus, error) {
167
- - u.mu.Lock()
168
- - if u.mode != "binary" {
169
- - u.mu.Unlock()
170
- - return api.UpdateStatus{}, updateConflictError("当前运行方式不支持在线更新")
171
- - }
172
- - if u.updating {
173
- - u.mu.Unlock()
174
- - return api.UpdateStatus{}, updateConflictError("更新正在进行")
175
- - }
176
- - u.updating = true
177
- - u.mu.Unlock()
178
- - release, err := u.fetchRelease(ctx)
179
- - if err == nil && semver.Compare(release.TagName, u.version) <= 0 {
180
- - err = updateConflictError("当前已是最新版本 " + u.version)
181
- - }
182
- - if err == nil {
183
- - u.log("INFO", "在线更新 | 1/2 | 下载发行包 | 版本="+release.TagName)
184
- - err = u.install(ctx, release)
185
- - }
186
- - u.mu.Lock()
187
- - defer u.mu.Unlock()
188
- - if err != nil {
189
- - u.updating = false
190
- - u.log("ERROR", "在线更新失败 | 错误="+err.Error())
191
- - return api.UpdateStatus{}, err
192
- - }
193
- - u.release, u.checked, u.checkErr = release, time.Now(), nil
194
- - u.log("INFO", "在线更新完成 | 版本="+release.TagName)
195
- - status := u.statusLocked()
196
- - u.restart()
197
- - return status, nil
198
- +func (managedUpdateDisabledError) Error() string {
199
- + return "在线更新已禁用,请通过 Magpie 插件升级 / Online updates are disabled; upgrade through the Magpie plugin."
200
- }
201
-
202
- -// statusLocked 在持有锁时生成更新状态
203
- -func (u *selfUpdater) statusLocked() api.UpdateStatus {
204
- - status := api.UpdateStatus{Current: u.version, Mode: u.mode, CheckedAt: u.checked, Updating: u.updating}
205
- - if u.checkErr != nil {
206
- - status.Error = u.checkErr.Error()
207
- - }
208
- - if release := u.release; release != nil {
209
- - status.Latest, status.Notes, status.URL, status.PublishedAt = release.TagName, release.Body, release.HTMLURL, release.PublishedAt
210
- - status.Available = semver.Compare(release.TagName, u.version) > 0
211
- - }
212
- - return status
213
- -}
214
- +func (managedUpdateDisabledError) HTTPStatus() int { return http.StatusForbidden }
215
-
216
- -// fetchRelease 读取 GitHub 最新发行版本
217
- -func (u *selfUpdater) fetchRelease(ctx context.Context) (*githubRelease, error) {
218
- - response, err := u.get(ctx, u.releaseURL, "application/vnd.github+json")
219
- - if err != nil {
220
- - return nil, fmt.Errorf("读取最新发行版本: %w", err)
221
- - }
222
- - defer response.Body.Close()
223
- - var release githubRelease
224
- - if err := json.NewDecoder(response.Body).Decode(&release); err != nil {
225
- - return nil, fmt.Errorf("解析最新发行版本: %w", err)
226
- - }
227
- - return &release, nil
228
- -}
229
- -
230
- -// install 下载当前系统与架构的发行包,校验 SHA-256 后替换可执行文件
231
- -func (u *selfUpdater) install(ctx context.Context, release *githubRelease) error {
232
- - name, binary := releasePackage(release.TagName)
233
- - index := slices.IndexFunc(release.Assets, func(asset githubAsset) bool { return asset.Name == name })
234
- - if index < 0 {
235
- - return fmt.Errorf("发行版本 %s 没有 %s", release.TagName, name)
236
- - }
237
- - asset := release.Assets[index]
238
- - expected, ok := strings.CutPrefix(asset.Digest, "sha256:")
239
- - if !ok {
240
- - return fmt.Errorf("发行包 %s 缺少 SHA-256 摘要", name)
241
- - }
242
- - response, err := u.get(ctx, asset.URL, "application/octet-stream")
243
- - if err != nil {
244
- - return fmt.Errorf("下载 %s: %w", name, err)
245
- - }
246
- - archive, err := io.ReadAll(response.Body)
247
- - response.Body.Close()
248
- - if err != nil {
249
- - return fmt.Errorf("下载 %s: %w", name, err)
250
- - }
251
- - if sum := sha256.Sum256(archive); hex.EncodeToString(sum[:]) != expected {
252
- - return fmt.Errorf("发行包 %s 的 SHA-256 与发行版本记录不一致", name)
253
- - }
254
- - executable, err := extractExecutable(archive, name, binary)
255
- - if err != nil {
256
- - return err
257
- - }
258
- - u.log("INFO", "在线更新 | 2/2 | 替换可执行文件 | 版本="+release.TagName)
259
- - if err := selfupdate.Apply(bytes.NewReader(executable), selfupdate.Options{TargetPath: u.executable}); err != nil {
260
- - if rollback := selfupdate.RollbackError(err); rollback != nil {
261
- - return fmt.Errorf("替换可执行文件: %w;恢复原文件失败: %v", err, rollback)
262
- - }
263
- - return fmt.Errorf("替换可执行文件: %w", err)
264
- - }
265
- - return nil
266
- -}
267
- -
268
- -// get 经管理进程出口发出 GET,非 200 响应返回错误
269
- -func (u *selfUpdater) get(ctx context.Context, address, accept string) (*http.Response, error) {
270
- - transport := http.DefaultTransport.(*http.Transport).Clone()
271
- - if proxy := u.proxy(); proxy != "" {
272
- - proxyURL, err := url.Parse(proxy)
273
- - if err != nil {
274
- - return nil, fmt.Errorf("解析代理: %w", err)
275
- - }
276
- - transport.Proxy = http.ProxyURL(proxyURL)
277
- - }
278
- - request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
279
- - if err != nil {
280
- - return nil, err
281
- - }
282
- - request.Header.Set("Accept", accept)
283
- - response, err := (&http.Client{Transport: transport}).Do(request)
284
- - if err != nil {
285
- - return nil, err
286
- - }
287
- - if response.StatusCode != http.StatusOK {
288
- - body, _ := io.ReadAll(io.LimitReader(response.Body, 512))
289
- - response.Body.Close()
290
- - return nil, fmt.Errorf("HTTP %d %s", response.StatusCode, strings.TrimSpace(string(body)))
291
- - }
292
- - return response, nil
293
- -}
294
- -
295
- -// releasePackage 返回当前系统与架构的发行包文件名与其中的可执行文件名
296
- -func releasePackage(tag string) (string, string) {
297
- - base := fmt.Sprintf("aistudio2api-%s-%s-%s", tag, runtime.GOOS, runtime.GOARCH)
298
- - if runtime.GOOS == "windows" {
299
- - return base + ".zip", "aistudio2api.exe"
300
- - }
301
- - return base + ".tar.gz", "aistudio2api"
302
- -}
303
- -
304
- -// extractExecutable 从 zip 或 tar.gz 发行包中取出可执行文件
305
- -func extractExecutable(archive []byte, name, binary string) ([]byte, error) {
306
- - if strings.HasSuffix(name, ".zip") {
307
- - reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
308
- - if err != nil {
309
- - return nil, fmt.Errorf("读取发行包: %w", err)
310
- - }
311
- - for _, file := range reader.File {
312
- - if path.Base(file.Name) != binary {
313
- - continue
314
- - }
315
- - opened, err := file.Open()
316
- - if err != nil {
317
- - return nil, fmt.Errorf("读取发行包: %w", err)
318
- - }
319
- - defer opened.Close()
320
- - return io.ReadAll(opened)
321
- - }
322
- - return nil, fmt.Errorf("发行包中没有 %s", binary)
323
- - }
324
- - compressed, err := gzip.NewReader(bytes.NewReader(archive))
325
- - if err != nil {
326
- - return nil, fmt.Errorf("读取发行包: %w", err)
327
- - }
328
- - reader := tar.NewReader(compressed)
329
- - for {
330
- - header, err := reader.Next()
331
- - if errors.Is(err, io.EOF) {
332
- - return nil, fmt.Errorf("发行包中没有 %s", binary)
333
- - }
334
- - if err != nil {
335
- - return nil, fmt.Errorf("读取发行包: %w", err)
336
- - }
337
- - if header.Typeflag == tar.TypeReg && path.Base(header.Name) == binary {
338
- - return io.ReadAll(reader)
339
- - }
340
- - }
341
- +func (u *selfUpdater) ApplyUpdate(_ context.Context) (api.UpdateStatus, error) {
342
- + return api.UpdateStatus{}, managedUpdateDisabledError{}
343
- }
344
-
345
- -// relaunch 以原参数启动替换后的可执行文件:Windows 上新进程沿用当前控制台,其他系统原地替换进程
346
- +// Retained for the application's restart plumbing; the managed updater never
347
- +// invokes its restart callback.
348
- func relaunch(executable string, args []string) error {
349
- args = append(slices.Clone(args), "-open-ui=false")
350
- if runtime.GOOS == "windows" {
351
- diff --git a/internal/app/update_managed_test.go b/internal/app/update_managed_test.go
352
- new file mode 100644
353
- index 0000000..ce86d14
354
- --- /dev/null
355
- +++ b/internal/app/update_managed_test.go
356
- @@ -0,0 +1,41 @@
357
- +package app
358
- +
359
- +import (
360
- + "context"
361
- + "errors"
362
- + "net/http"
363
- + "os"
364
- + "path/filepath"
365
- + "testing"
366
- +)
367
- +
368
- +func TestManagedUpdaterCannotUpdateOrRestart(t *testing.T) {
369
- + executable := filepath.Join(t.TempDir(), "aistudio2api.exe")
370
- + original := []byte("existing executable")
371
- + if err := os.WriteFile(executable, original, 0600); err != nil {
372
- + t.Fatal(err)
373
- + }
374
- + for _, version := range []string{"v0.2.13", "v0.2.13-magpie.1", "(devel)"} {
375
- + t.Run(version, func(t *testing.T) {
376
- + u := newSelfUpdater(version, executable,
377
- + func() string { t.Fatal("must not query network proxy"); return "" },
378
- + func(string, string) { t.Fatal("must not enter the updater") },
379
- + func() { t.Fatal("must not restart") })
380
- + for _, refresh := range []bool{false, true} {
381
- + status, err := u.UpdateStatus(context.Background(), refresh)
382
- + if err != nil || status.Mode != "managed" || status.Available || status.Updating || status.Latest != "" || status.Current != version {
383
- + t.Fatalf("unexpected status: %+v, %v", status, err)
384
- + }
385
- + }
386
- + _, err := u.ApplyUpdate(context.Background())
387
- + var httpError interface{ HTTPStatus() int }
388
- + if !errors.As(err, &httpError) || httpError.HTTPStatus() != http.StatusForbidden {
389
- + t.Fatalf("expected forbidden: %v", err)
390
- + }
391
- + data, err := os.ReadFile(executable)
392
- + if err != nil || string(data) != string(original) {
393
- + t.Fatalf("executable changed: %v", err)
394
- + }
395
- + })
396
- + }
397
- +}
398
- diff --git a/web/src/components/VersionPanel.vue b/web/src/components/VersionPanel.vue
399
- index 98d7968..a4ed70a 100644
400
- --- a/web/src/components/VersionPanel.vue
401
- +++ b/web/src/components/VersionPanel.vue
402
- @@ -1,193 +1,30 @@
403
- <script setup lang="ts">
404
- -import { computed, onActivated, ref } from 'vue'
405
- -import { api } from '@/api'
406
- +import { computed } from 'vue'
407
- import { useI18n } from '@/i18n'
408
- -import { markdown } from '@/markdown'
409
- import type { UpdateStatus } from '@/types'
410
- -import UiIcon from '@/components/UiIcon.vue'
411
-
412
- -const props = defineProps<{ status: UpdateStatus | null; activeRequests: number }>()
413
- -const emit = defineEmits<{ status: [UpdateStatus]; restarting: [] }>()
414
- -const { t, locale, errorText } = useI18n()
415
- -const checking = ref(false)
416
- -const confirming = ref(false)
417
- -const phase = ref<'idle' | 'installing' | 'restarting'>('idle')
418
- -const error = ref('')
419
- -
420
- -const latest = computed(() => props.status?.latest ?? '')
421
- -const notes = computed(() => markdown.render(props.status?.notes ?? ''))
422
- -const publishedAt = computed(() =>
423
- - props.status?.published_at
424
- - ? new Date(props.status.published_at).toLocaleString(locale.value)
425
- - : '',
426
- -)
427
- -const installable = computed(
428
- - () => props.status?.mode === 'binary' && props.status.available && !props.status.updating,
429
- +defineProps<{ status: UpdateStatus | null; activeRequests: number }>()
430
- +defineEmits<{ status: [UpdateStatus]; restarting: [] }>()
431
- +const { t, locale } = useI18n()
432
- +const message = computed(() =>
433
- + locale.value.startsWith('zh')
434
- + ? '在线更新已禁用,请通过 Magpie 插件升级。'
435
- + : 'Online updates are disabled. Upgrade through the Magpie plugin.',
436
- )
437
- -const summary = computed(() => {
438
- - if (phase.value === 'installing') return t('version.installing')
439
- - if (phase.value === 'restarting') return t('version.restarting')
440
- - if (props.status?.available) return t('version.available').replace('{version}', latest.value)
441
- - if (latest.value) return t('version.upToDate')
442
- - return ''
443
- -})
444
- -const confirmText = computed(() =>
445
- - t('version.confirmText')
446
- - .replace('{version}', latest.value)
447
- - .replace('{count}', String(props.activeRequests)),
448
- -)
449
- -
450
- -// check 读取 GitHub 最新发行版本,refresh 为 true 时跳过服务端缓存
451
- -async function check(refresh: boolean): Promise<void> {
452
- - checking.value = true
453
- - error.value = ''
454
- - try {
455
- - emit('status', await api.updateStatus(refresh))
456
- - } catch (reason) {
457
- - error.value = errorText(reason)
458
- - } finally {
459
- - checking.value = false
460
- - }
461
- -}
462
- -
463
- -// install 下载并替换为最新版本,成功后通知上层等待重启
464
- -async function install(): Promise<void> {
465
- - confirming.value = false
466
- - phase.value = 'installing'
467
- - error.value = ''
468
- - try {
469
- - emit('status', await api.applyUpdate())
470
- - } catch (reason) {
471
- - phase.value = 'idle'
472
- - error.value = errorText(reason)
473
- - return
474
- - }
475
- - phase.value = 'restarting'
476
- - emit('restarting')
477
- -}
478
- -
479
- -onActivated(() => void check(false))
480
- </script>
481
-
482
- <template>
483
- - <section class="flex min-h-0 flex-1 flex-col overflow-auto md:overflow-hidden">
484
- - <header class="flex flex-wrap items-center gap-3 border-b border-line px-4 py-4 md:px-8">
485
- - <div class="min-w-0 flex-1">
486
- - <h2 class="text-xl font-semibold text-white">{{ t('section.version.title') }}</h2>
487
- - <p class="mt-1 text-sm text-gray-500">{{ t('section.version.description') }}</p>
488
- - </div>
489
- + <section class="flex min-h-0 flex-1 flex-col overflow-auto">
490
- + <header class="border-b border-line px-4 py-4 md:px-8">
491
- + <h2 class="text-xl font-semibold text-white">{{ t('section.version.title') }}</h2>
492
- </header>
493
- - <div class="mx-auto flex min-h-0 w-full max-w-4xl flex-1 flex-col p-4 md:p-8">
494
- - <article class="shrink-0 rounded-lg border border-line bg-panel p-4 md:p-6">
495
- - <dl class="grid gap-4 sm:grid-cols-2">
496
- - <div>
497
- - <dt class="text-xs text-gray-500">{{ t('version.current') }}</dt>
498
- - <dd class="mt-1 font-mono text-lg break-all text-white">
499
- - {{ status?.current ?? '—' }}
500
- - </dd>
501
- - </div>
502
- - <div>
503
- - <dt class="text-xs text-gray-500">{{ t('version.latest') }}</dt>
504
- - <dd class="mt-1 font-mono text-lg text-white">{{ latest || '—' }}</dd>
505
- - <dd v-if="publishedAt" class="mt-0.5 text-xs text-gray-500">{{ publishedAt }}</dd>
506
- - </div>
507
- + <div class="mx-auto w-full max-w-4xl p-4 md:p-8">
508
- + <article class="rounded-lg border border-line bg-panel p-4 md:p-6">
509
- + <dl>
510
- + <dt class="text-xs text-gray-500">{{ t('version.current') }}</dt>
511
- + <dd class="mt-1 font-mono text-lg break-all text-white">{{ status?.current ?? '—' }}</dd>
512
- </dl>
513
- -
514
- - <p
515
- - v-if="summary"
516
- - class="mt-4 text-sm"
517
- - :class="status?.available || phase !== 'idle' ? 'text-blue-300' : 'text-gray-400'"
518
- - role="status"
519
- - >
520
- - {{ summary }}
521
- - </p>
522
- -
523
- - <div class="mt-4 flex flex-wrap items-center gap-2">
524
- - <button
525
- - type="button"
526
- - class="flex items-center gap-2 rounded border border-line bg-raised px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-line disabled:opacity-50"
527
- - :disabled="checking || phase !== 'idle'"
528
- - @click="check(true)"
529
- - >
530
- - <UiIcon :name="checking ? 'spinner' : 'refresh'" :size="15" />
531
- - {{ t('version.check') }}
532
- - </button>
533
- - <button
534
- - v-if="installable && !confirming"
535
- - type="button"
536
- - class="flex items-center gap-2 rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 disabled:opacity-50"
537
- - :disabled="phase !== 'idle'"
538
- - @click="confirming = true"
539
- - >
540
- - <UiIcon :name="phase === 'idle' ? 'download' : 'spinner'" :size="15" />
541
- - {{ t('version.install').replace('{version}', latest) }}
542
- - </button>
543
- - <a
544
- - v-if="status?.url"
545
- - :href="status.url"
546
- - target="_blank"
547
- - rel="noreferrer"
548
- - class="px-2 py-2 text-sm text-blue-400 hover:underline"
549
- - >
550
- - {{ t('version.release') }}
551
- - </a>
552
- - </div>
553
- -
554
- - <div
555
- - v-if="confirming"
556
- - role="alert"
557
- - class="mt-4 rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-200"
558
- - >
559
- - <p>{{ confirmText }}</p>
560
- - <div class="mt-3 flex flex-wrap gap-2">
561
- - <button
562
- - type="button"
563
- - class="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500"
564
- - @click="install"
565
- - >
566
- - {{ t('version.confirm') }}
567
- - </button>
568
- - <button
569
- - type="button"
570
- - class="rounded border border-line bg-raised px-4 py-2 text-sm font-medium text-gray-200 transition hover:bg-line"
571
- - @click="confirming = false"
572
- - >
573
- - {{ t('common.cancel') }}
574
- - </button>
575
- - </div>
576
- - </div>
577
- -
578
- - <div v-if="status?.mode === 'container'" class="mt-4 text-sm text-gray-400">
579
- - <p>{{ t('version.container') }}</p>
580
- - <code
581
- - class="mt-2 block overflow-x-auto rounded border border-line bg-canvas px-3 py-2 font-mono text-xs text-gray-200"
582
- - >docker compose pull &amp;&amp; docker compose up -d</code
583
- - >
584
- - </div>
585
- - <p v-else-if="status?.mode === 'development'" class="mt-4 text-sm text-gray-400">
586
- - {{ t('version.development') }}
587
- - </p>
588
- -
589
- - <p
590
- - v-if="error || status?.error"
591
- - role="alert"
592
- - class="mt-4 rounded border border-red-500/40 bg-red-500/10 p-3 text-sm break-words text-red-300"
593
- - >
594
- - {{ error || status?.error }}
595
- - </p>
596
- - </article>
597
- -
598
- - <article
599
- - v-if="status?.notes"
600
- - class="mt-6 flex min-h-0 flex-1 flex-col rounded-lg border border-line bg-panel p-4 md:p-6"
601
- - >
602
- - <h3 class="font-bold text-gray-300">
603
- - {{ t('version.notes').replace('{version}', latest) }}
604
- - </h3>
605
- - <div
606
- - class="markdown mt-3 min-h-0 flex-1 text-sm leading-6 [overflow-wrap:anywhere] text-gray-300 md:overflow-y-auto"
607
- - v-html="notes"
608
- - />
609
- + <p class="mt-4 text-sm text-gray-400" role="status">{{ message }}</p>
610
- </article>
611
- </div>
612
- </section>
613
- diff --git a/web/src/types.ts b/web/src/types.ts
614
- index e5c07cc..98a027b 100644
615
- --- a/web/src/types.ts
616
- +++ b/web/src/types.ts
617
- @@ -88,7 +88,7 @@ export interface UpdateStatus {
618
- current: string
619
- latest?: string
620
- available: boolean
621
- - mode: 'binary' | 'container' | 'development'
622
- + mode: 'binary' | 'container' | 'development' | 'managed'
623
- notes?: string
624
- url?: string
625
- published_at?: string