mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
fix(ui): keep the add-ons list on screen while the server is busy
A page loaded while the owner was busy had no list to draw, and a failed read could look like an empty install. The apps list read now carries the stale mark of the server. The page keeps the last list (from the server, or from the copy this tab stored), shows a notice that names the add-ons being upgraded, offers a retry, and asks again with the existing backoff. Each add-on that is changing shows "Upgrading..." with its phase. An error, or a stale copy of an empty list, is a load error, and the empty state appears only when the owner answered with an empty list. The new strings are in all eight locales. Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
20 files changed
+597
-34
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { ADMIN, app, installedAddon, sessionFor, mockAddons, fail } from './addons-fixtures.js'
|
||||
|
||||
// The owner is busy with an upgrade. The routes are stand-ins
|
||||
// (addons-fixtures.js); the owner's own behaviour is covered by the Go specs
|
||||
// "reads during a long upgrade" and "apps list while the owner is busy".
|
||||
|
||||
const shots = process.env.BUSY_SHOTS
|
||||
const theme = process.env.BUSY_THEME
|
||||
async function shoot(page, name) {
|
||||
if (!shots) return
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.waitForTimeout(400)
|
||||
await page.screenshot({ path: `${shots}/${name}${process.env.BUSY_SUFFIX || ''}.png`, fullPage: false })
|
||||
}
|
||||
|
||||
const adminOf = (id, running = true) => ({ canStart: !running, canStop: running, managementUrl: `/app/addons?manage=${id}` })
|
||||
const notes = (over = {}) => app({ id: 'notes', name: 'notes', title: 'Notes', icon: undefined, mainPage: undefined, admin: adminOf('notes'), ...over })
|
||||
const sandbox = (over = {}) => app({ id: 'sandbox', name: 'sandbox', title: 'Sandbox', description: 'Run commands in a guest.', icon: undefined, mainPage: undefined, admin: adminOf('sandbox'), generation: 5, version: '2.1.0', ...over })
|
||||
const tables = (over = {}) => app({ id: 'tables', name: 'tables', title: 'Tables', description: 'Spreadsheets.', icon: undefined, mainPage: undefined, admin: adminOf('tables'), generation: 2, version: '0.9.0', ...over })
|
||||
const upgrading = (phase, since = '2026-11-04T17:18:00Z') => ({ kind: 'upgrade', phase, since })
|
||||
const tile = (page, id) => page.locator(`[data-testid="addon-tile"][data-app="${id}"]`)
|
||||
const body = (payload) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(payload) })
|
||||
|
||||
async function signIn(page) {
|
||||
if (theme) await page.addInitScript((value) => { try { localStorage.setItem('localai-theme', value) } catch { /* private window */ } }, theme)
|
||||
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) }))
|
||||
}
|
||||
|
||||
test.describe('Add-ons: the owner is busy', () => {
|
||||
test('an error on the first read is not "no add-ons": the page says the server is busy and a retry loads the list', async ({ page }) => {
|
||||
await signIn(page)
|
||||
let busy = true
|
||||
await mockAddons(page, {
|
||||
apps: [notes(), tables()],
|
||||
installed: [installedAddon({ id: 'notes' }), installedAddon({ id: 'tables', title: 'Tables' })],
|
||||
appsList: () => (busy ? fail(503, 'owner_busy', { message: 'busy', code: 503 }) : null),
|
||||
})
|
||||
await page.goto('/app/addons')
|
||||
const error = page.getByTestId('addons-load-error')
|
||||
await expect(error).toBeVisible()
|
||||
await expect(error).toContainText('did not answer in time')
|
||||
await expect(page.getByText('No add-ons yet')).toHaveCount(0)
|
||||
await expect(page.getByTestId('addon-tile')).toHaveCount(0)
|
||||
await shoot(page, 'busy-first-load')
|
||||
busy = false
|
||||
await error.getByRole('button').click()
|
||||
await expect(tile(page, 'notes')).toBeVisible()
|
||||
await expect(tile(page, 'tables')).toBeVisible()
|
||||
await expect(page.getByTestId('addons-load-error')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a plain 500 is an error too, never the empty state', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await mockAddons(page, { apps: [notes()], appsList: () => fail(500, 'server_error') })
|
||||
await page.goto('/app/addons')
|
||||
await expect(page.getByTestId('addons-load-error')).toBeVisible()
|
||||
await expect(page.getByText('No add-ons yet')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a stale list from the server keeps the rows, names what is upgrading and shows the phase of each row', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await mockAddons(page, {
|
||||
apps: [notes(), sandbox(), tables()],
|
||||
installed: [installedAddon({ id: 'notes' })],
|
||||
appsList: () => body({
|
||||
apps: [notes({ operation: upgrading('downloading') }), sandbox({ operation: upgrading('starting') }), tables()],
|
||||
stale: true, staleReason: 'owner_busy', staleSince: '2026-11-04T17:18:30Z',
|
||||
}),
|
||||
})
|
||||
await page.goto('/app/addons')
|
||||
const banner = page.getByTestId('addons-stale')
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toContainText('The add-on server is busy (upgrading Notes, Sandbox)')
|
||||
await expect(banner).toContainText('Showing the last known state.')
|
||||
await expect(tile(page, 'tables')).toBeVisible()
|
||||
await expect(tile(page, 'notes').getByTestId('operation-chip')).toContainText('Upgrading… · downloading and checking')
|
||||
await expect(tile(page, 'sandbox').getByTestId('operation-chip')).toContainText('Upgrading… · starting')
|
||||
await expect(tile(page, 'tables').getByTestId('operation-chip')).toHaveCount(0)
|
||||
await expect(page.getByText('No add-ons yet')).toHaveCount(0)
|
||||
await shoot(page, 'busy-stale-list')
|
||||
})
|
||||
|
||||
test('a live answer with an upgrade in flight shows the rows as upgrading and no banner', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await mockAddons(page, { apps: [notes({ operation: upgrading('backing-up') }), tables()] })
|
||||
await page.goto('/app/addons')
|
||||
await expect(tile(page, 'notes').getByTestId('operation-chip')).toContainText('backing up data')
|
||||
await expect(page.getByTestId('addons-stale')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a refresh while the server is busy shows the last list this tab got, marked as last known, and recovers by itself', async ({ page }) => {
|
||||
await signIn(page)
|
||||
let busy = false
|
||||
await mockAddons(page, {
|
||||
apps: [notes(), tables()],
|
||||
appsList: () => (busy ? fail(503, 'owner_busy', { message: 'busy', code: 503 }) : null),
|
||||
})
|
||||
await page.goto('/app/addons')
|
||||
await expect(tile(page, 'notes')).toBeVisible()
|
||||
busy = true
|
||||
await page.reload()
|
||||
const banner = page.getByTestId('addons-stale')
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(banner).toContainText('Showing the last known state.')
|
||||
await expect(tile(page, 'notes')).toBeVisible()
|
||||
await expect(tile(page, 'tables')).toBeVisible()
|
||||
await expect(page.getByTestId('addons-load-error')).toHaveCount(0)
|
||||
await expect(page.getByText('No add-ons yet')).toHaveCount(0)
|
||||
await shoot(page, 'busy-last-known')
|
||||
busy = false
|
||||
await banner.getByTestId('addons-stale-retry').click()
|
||||
await expect(page.getByTestId('addons-stale')).toHaveCount(0)
|
||||
await expect(tile(page, 'notes')).toBeVisible()
|
||||
})
|
||||
|
||||
test('an empty list the owner answered is the only empty state', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await mockAddons(page, { apps: [] })
|
||||
await page.goto('/app/addons')
|
||||
await expect(page.getByText('No add-ons yet').first()).toBeVisible()
|
||||
await expect(page.getByTestId('addons-load-error')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a stale copy of an empty list is not the empty state either', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await mockAddons(page, { apps: [], appsList: () => body({ apps: [], stale: true, staleReason: 'owner_busy' }) })
|
||||
await page.goto('/app/addons')
|
||||
await expect(page.getByTestId('addons-load-error')).toBeVisible()
|
||||
await expect(page.getByText('No add-ons yet')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -66,6 +66,13 @@
|
||||
"loadError": {
|
||||
"title": "Add-ons sind gerade nicht verfügbar.",
|
||||
"paused": "Add-on-Seiten und -Werkzeuge pausieren, bis er wieder da ist."
|
||||
},
|
||||
"busy": {
|
||||
"upgrading": "Der Add-on-Server ist beschäftigt (aktualisiert {{names}})",
|
||||
"generic": "Der Add-on-Server ist beschäftigt",
|
||||
"unreachable": "Der Add-on-Server ist nicht erreichbar",
|
||||
"reason": "Es wird der zuletzt bekannte Stand gezeigt. Die Seite fragt selbstständig erneut.",
|
||||
"retry": "Jetzt erneut versuchen"
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
@@ -138,7 +145,8 @@
|
||||
"invalid": "Der Server konnte diese Anfrage nicht annehmen.",
|
||||
"network": "Der Server ist nicht erreichbar. Prüfe deine Verbindung.",
|
||||
"unavailable": "Add-ons sind gerade nicht verfügbar. Versuche es gleich noch einmal.",
|
||||
"unknownOutcome": "Das Ergebnis konnte nicht bestätigt werden. Prüfe den Status, bevor du es erneut versuchst."
|
||||
"unknownOutcome": "Das Ergebnis konnte nicht bestätigt werden. Prüfe den Status, bevor du es erneut versuchst.",
|
||||
"busy": "Der Add-on-Server ist beschäftigt und hat nicht rechtzeitig geantwortet. Versuche es gleich noch einmal."
|
||||
},
|
||||
"installed": {
|
||||
"disabled": "Aus",
|
||||
@@ -935,5 +943,25 @@
|
||||
"enableFailedTitle": "{{name}} konnte nicht eingeschaltet werden",
|
||||
"incompatibleTitle": "{{name}} lässt sich nicht einschalten: nicht mit diesem Server kompatibel",
|
||||
"noUpgrade": "Es ist noch keine neuere Version gelistet. Lass es aus oder entferne es."
|
||||
},
|
||||
"operation": {
|
||||
"kind": {
|
||||
"install": "Wird installiert…",
|
||||
"upgrade": "Wird aktualisiert…",
|
||||
"uninstall": "Wird entfernt…",
|
||||
"enable": "Wird gestartet…",
|
||||
"disable": "Wird gestoppt…",
|
||||
"working": "In Arbeit…"
|
||||
},
|
||||
"label": "In Arbeit: {{operation}}",
|
||||
"phase": {
|
||||
"queued": "wartet",
|
||||
"downloading": "Download und Prüfung",
|
||||
"backing-up": "Datensicherung",
|
||||
"installing": "Installation",
|
||||
"migrating": "Datenmigration",
|
||||
"starting": "Start",
|
||||
"working": "in Arbeit"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,13 @@
|
||||
"loadError": {
|
||||
"title": "Add-ons are not available right now.",
|
||||
"paused": "Add-on pages and tools are paused until it is back."
|
||||
},
|
||||
"busy": {
|
||||
"upgrading": "The add-on server is busy (upgrading {{names}})",
|
||||
"generic": "The add-on server is busy",
|
||||
"unreachable": "The add-on server cannot be reached",
|
||||
"reason": "Showing the last known state. This page asks again by itself.",
|
||||
"retry": "Try again now"
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
@@ -138,7 +145,8 @@
|
||||
"invalid": "The server could not accept this request.",
|
||||
"network": "The server could not be reached. Check your connection.",
|
||||
"unavailable": "Add-ons are not available right now. Try again in a moment.",
|
||||
"unknownOutcome": "The result could not be confirmed. Check the status before you try again."
|
||||
"unknownOutcome": "The result could not be confirmed. Check the status before you try again.",
|
||||
"busy": "The add-on server is busy and did not answer in time. Try again in a moment."
|
||||
},
|
||||
"installed": {
|
||||
"disabled": "Off",
|
||||
@@ -935,5 +943,25 @@
|
||||
"enableFailedTitle": "Could not turn on {{name}}",
|
||||
"incompatibleTitle": "Cannot turn on {{name}}: not compatible with this server",
|
||||
"noUpgrade": "No newer version is listed yet. Keep it off, or remove it."
|
||||
},
|
||||
"operation": {
|
||||
"kind": {
|
||||
"install": "Installing…",
|
||||
"upgrade": "Upgrading…",
|
||||
"uninstall": "Removing…",
|
||||
"enable": "Starting…",
|
||||
"disable": "Stopping…",
|
||||
"working": "Working…"
|
||||
},
|
||||
"label": "In progress: {{operation}}",
|
||||
"phase": {
|
||||
"queued": "waiting",
|
||||
"downloading": "downloading and checking",
|
||||
"backing-up": "backing up data",
|
||||
"installing": "installing",
|
||||
"migrating": "migrating data",
|
||||
"starting": "starting",
|
||||
"working": "working"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,13 @@
|
||||
"loadError": {
|
||||
"title": "Los complementos no están disponibles ahora.",
|
||||
"paused": "Las páginas y herramientas de los complementos quedan en pausa hasta que vuelva."
|
||||
},
|
||||
"busy": {
|
||||
"upgrading": "El servidor de complementos está ocupado (actualizando {{names}})",
|
||||
"generic": "El servidor de complementos está ocupado",
|
||||
"unreachable": "No se puede acceder al servidor de complementos",
|
||||
"reason": "Se muestra el último estado conocido. La página lo intenta de nuevo sola.",
|
||||
"retry": "Reintentar ahora"
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
@@ -138,7 +145,8 @@
|
||||
"invalid": "El servidor no pudo aceptar la solicitud.",
|
||||
"network": "No se pudo contactar con el servidor. Revisa tu conexión.",
|
||||
"unavailable": "Los complementos no están disponibles ahora. Inténtalo de nuevo en un momento.",
|
||||
"unknownOutcome": "No se pudo confirmar el resultado. Comprueba el estado antes de volver a intentarlo."
|
||||
"unknownOutcome": "No se pudo confirmar el resultado. Comprueba el estado antes de volver a intentarlo.",
|
||||
"busy": "El servidor de complementos está ocupado y no respondió a tiempo. Inténtalo de nuevo en un momento."
|
||||
},
|
||||
"installed": {
|
||||
"disabled": "Desactivado",
|
||||
@@ -935,5 +943,25 @@
|
||||
"enableFailedTitle": "No se pudo activar {{name}}",
|
||||
"incompatibleTitle": "No se puede activar {{name}}: no es compatible con este servidor",
|
||||
"noUpgrade": "Todavía no hay una versión más reciente en la lista. Déjalo desactivado o quítalo."
|
||||
},
|
||||
"operation": {
|
||||
"kind": {
|
||||
"install": "Instalando…",
|
||||
"upgrade": "Actualizando…",
|
||||
"uninstall": "Quitando…",
|
||||
"enable": "Iniciando…",
|
||||
"disable": "Deteniendo…",
|
||||
"working": "Trabajando…"
|
||||
},
|
||||
"label": "En curso: {{operation}}",
|
||||
"phase": {
|
||||
"queued": "en espera",
|
||||
"downloading": "descarga y comprobación",
|
||||
"backing-up": "copia de los datos",
|
||||
"installing": "instalación",
|
||||
"migrating": "migración de datos",
|
||||
"starting": "inicio",
|
||||
"working": "en curso"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,13 @@
|
||||
"loadError": {
|
||||
"title": "Les add-ons ne sont pas disponibles pour le moment.",
|
||||
"paused": "Les pages et outils des add-ons sont en pause jusqu’à son retour."
|
||||
},
|
||||
"busy": {
|
||||
"upgrading": "Le serveur de modules est occupé (mise à jour de {{names}})",
|
||||
"generic": "Le serveur de modules est occupé",
|
||||
"unreachable": "Le serveur de modules est injoignable",
|
||||
"reason": "Dernier état connu affiché. La page réessaie toute seule.",
|
||||
"retry": "Réessayer maintenant"
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
@@ -141,7 +148,8 @@
|
||||
"invalid": "Le serveur n'a pas pu accepter cette demande.",
|
||||
"network": "Le serveur est injoignable. Vérifiez votre connexion.",
|
||||
"unavailable": "Les modules ne sont pas disponibles pour le moment. Réessayez dans un instant.",
|
||||
"unknownOutcome": "Le résultat n'a pas pu être confirmé. Vérifiez l'état avant de réessayer."
|
||||
"unknownOutcome": "Le résultat n'a pas pu être confirmé. Vérifiez l'état avant de réessayer.",
|
||||
"busy": "Le serveur de modules est occupé et n'a pas répondu à temps. Réessayez dans un instant."
|
||||
},
|
||||
"installed": {
|
||||
"disabled": "Désactivé",
|
||||
@@ -939,5 +947,25 @@
|
||||
"enableFailedTitle": "Impossible d'activer {{name}}",
|
||||
"incompatibleTitle": "Impossible d'activer {{name}} : incompatible avec ce serveur",
|
||||
"noUpgrade": "Aucune version plus récente n'est encore listée. Laissez-le désactivé ou supprimez-le."
|
||||
},
|
||||
"operation": {
|
||||
"kind": {
|
||||
"install": "Installation…",
|
||||
"upgrade": "Mise à jour…",
|
||||
"uninstall": "Suppression…",
|
||||
"enable": "Démarrage…",
|
||||
"disable": "Arrêt…",
|
||||
"working": "En cours…"
|
||||
},
|
||||
"label": "En cours : {{operation}}",
|
||||
"phase": {
|
||||
"queued": "en attente",
|
||||
"downloading": "téléchargement et vérification",
|
||||
"backing-up": "sauvegarde des données",
|
||||
"installing": "installation",
|
||||
"migrating": "migration des données",
|
||||
"starting": "démarrage",
|
||||
"working": "en cours"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,13 @@
|
||||
"loadError": {
|
||||
"title": "Add-on tidak tersedia saat ini.",
|
||||
"paused": "Halaman dan alat add-on dijeda sampai layanan kembali."
|
||||
},
|
||||
"busy": {
|
||||
"upgrading": "Server add-on sedang sibuk (memperbarui {{names}})",
|
||||
"generic": "Server add-on sedang sibuk",
|
||||
"unreachable": "Server add-on tidak dapat dijangkau",
|
||||
"reason": "Menampilkan status terakhir yang diketahui. Halaman ini mencoba lagi sendiri.",
|
||||
"retry": "Coba lagi sekarang"
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
@@ -138,7 +145,8 @@
|
||||
"invalid": "Server tidak dapat menerima permintaan ini.",
|
||||
"network": "Server tidak dapat dijangkau. Periksa koneksi Anda.",
|
||||
"unavailable": "Add-on tidak tersedia saat ini. Coba lagi sebentar lagi.",
|
||||
"unknownOutcome": "Hasil tidak dapat dikonfirmasi. Periksa statusnya sebelum mencoba lagi."
|
||||
"unknownOutcome": "Hasil tidak dapat dikonfirmasi. Periksa statusnya sebelum mencoba lagi.",
|
||||
"busy": "Server add-on sedang sibuk dan tidak menjawab tepat waktu. Coba lagi sebentar lagi."
|
||||
},
|
||||
"installed": {
|
||||
"disabled": "Nonaktif",
|
||||
@@ -935,5 +943,25 @@
|
||||
"enableFailedTitle": "{{name}} tidak dapat diaktifkan",
|
||||
"incompatibleTitle": "{{name}} tidak dapat diaktifkan: tidak kompatibel dengan server ini",
|
||||
"noUpgrade": "Belum ada versi yang lebih baru di daftar. Biarkan mati, atau hapus."
|
||||
},
|
||||
"operation": {
|
||||
"kind": {
|
||||
"install": "Memasang…",
|
||||
"upgrade": "Memperbarui…",
|
||||
"uninstall": "Menghapus…",
|
||||
"enable": "Memulai…",
|
||||
"disable": "Menghentikan…",
|
||||
"working": "Bekerja…"
|
||||
},
|
||||
"label": "Sedang berjalan: {{operation}}",
|
||||
"phase": {
|
||||
"queued": "menunggu",
|
||||
"downloading": "mengunduh dan memeriksa",
|
||||
"backing-up": "mencadangkan data",
|
||||
"installing": "memasang",
|
||||
"migrating": "memigrasi data",
|
||||
"starting": "memulai",
|
||||
"working": "bekerja"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,13 @@
|
||||
"loadError": {
|
||||
"title": "I componenti aggiuntivi non sono disponibili ora.",
|
||||
"paused": "Pagine e strumenti dei componenti sono in pausa finché non torna."
|
||||
},
|
||||
"busy": {
|
||||
"upgrading": "Il server dei componenti è occupato (aggiorna {{names}})",
|
||||
"generic": "Il server dei componenti è occupato",
|
||||
"unreachable": "Il server dei componenti non è raggiungibile",
|
||||
"reason": "Mostro l'ultimo stato noto. La pagina riprova da sola.",
|
||||
"retry": "Riprova ora"
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
@@ -138,7 +145,8 @@
|
||||
"invalid": "Il server non ha potuto accettare la richiesta.",
|
||||
"network": "Impossibile raggiungere il server. Controlla la connessione.",
|
||||
"unavailable": "I componenti non sono disponibili al momento. Riprova tra poco.",
|
||||
"unknownOutcome": "Non è stato possibile confermare il risultato. Controlla lo stato prima di riprovare."
|
||||
"unknownOutcome": "Non è stato possibile confermare il risultato. Controlla lo stato prima di riprovare.",
|
||||
"busy": "Il server dei componenti è occupato e non ha risposto in tempo. Riprova tra poco."
|
||||
},
|
||||
"installed": {
|
||||
"disabled": "Disattivato",
|
||||
@@ -935,5 +943,25 @@
|
||||
"enableFailedTitle": "Impossibile attivare {{name}}",
|
||||
"incompatibleTitle": "Impossibile attivare {{name}}: non compatibile con questo server",
|
||||
"noUpgrade": "Non è ancora elencata una versione più recente. Lascialo disattivato o rimuovilo."
|
||||
},
|
||||
"operation": {
|
||||
"kind": {
|
||||
"install": "Installazione…",
|
||||
"upgrade": "Aggiornamento…",
|
||||
"uninstall": "Rimozione…",
|
||||
"enable": "Avvio…",
|
||||
"disable": "Arresto…",
|
||||
"working": "In corso…"
|
||||
},
|
||||
"label": "In corso: {{operation}}",
|
||||
"phase": {
|
||||
"queued": "in attesa",
|
||||
"downloading": "download e verifica",
|
||||
"backing-up": "copia dei dati",
|
||||
"installing": "installazione",
|
||||
"migrating": "migrazione dei dati",
|
||||
"starting": "avvio",
|
||||
"working": "in corso"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,13 @@
|
||||
"loadError": {
|
||||
"title": "지금은 애드온을 사용할 수 없습니다.",
|
||||
"paused": "서비스가 돌아올 때까지 애드온 페이지와 도구는 일시 중지됩니다."
|
||||
},
|
||||
"busy": {
|
||||
"upgrading": "애드온 서버가 바쁩니다({{names}} 업그레이드 중)",
|
||||
"generic": "애드온 서버가 바쁩니다",
|
||||
"unreachable": "애드온 서버에 연결할 수 없습니다",
|
||||
"reason": "마지막으로 알려진 상태를 표시합니다. 이 페이지는 스스로 다시 시도합니다.",
|
||||
"retry": "지금 다시 시도"
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
@@ -138,7 +145,8 @@
|
||||
"invalid": "서버가 이 요청을 받아들일 수 없습니다.",
|
||||
"network": "서버에 연결할 수 없습니다. 연결을 확인하세요.",
|
||||
"unavailable": "지금은 애드온을 사용할 수 없습니다. 잠시 후 다시 시도하세요.",
|
||||
"unknownOutcome": "결과를 확인할 수 없습니다. 다시 시도하기 전에 상태를 확인하세요."
|
||||
"unknownOutcome": "결과를 확인할 수 없습니다. 다시 시도하기 전에 상태를 확인하세요.",
|
||||
"busy": "애드온 서버가 바빠서 제때 응답하지 못했습니다. 잠시 후 다시 시도하세요."
|
||||
},
|
||||
"installed": {
|
||||
"disabled": "꺼짐",
|
||||
@@ -935,5 +943,25 @@
|
||||
"enableFailedTitle": "{{name}}을(를) 켤 수 없습니다",
|
||||
"incompatibleTitle": "{{name}}을(를) 켤 수 없습니다: 이 서버와 호환되지 않습니다",
|
||||
"noUpgrade": "아직 더 새로운 버전이 목록에 없습니다. 꺼 두거나 제거하세요."
|
||||
},
|
||||
"operation": {
|
||||
"kind": {
|
||||
"install": "설치 중…",
|
||||
"upgrade": "업그레이드 중…",
|
||||
"uninstall": "제거 중…",
|
||||
"enable": "시작 중…",
|
||||
"disable": "중지 중…",
|
||||
"working": "작업 중…"
|
||||
},
|
||||
"label": "진행 중: {{operation}}",
|
||||
"phase": {
|
||||
"queued": "대기 중",
|
||||
"downloading": "다운로드 및 확인",
|
||||
"backing-up": "데이터 백업",
|
||||
"installing": "설치",
|
||||
"migrating": "데이터 마이그레이션",
|
||||
"starting": "시작",
|
||||
"working": "작업 중"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,13 @@
|
||||
"loadError": {
|
||||
"title": "Os complementos não estão disponíveis agora.",
|
||||
"paused": "Páginas e ferramentas dos complementos ficam pausadas até ele voltar."
|
||||
},
|
||||
"busy": {
|
||||
"upgrading": "O servidor de complementos está ocupado (atualizando {{names}})",
|
||||
"generic": "O servidor de complementos está ocupado",
|
||||
"unreachable": "Não foi possível alcançar o servidor de complementos",
|
||||
"reason": "Mostrando o último estado conhecido. A página tenta de novo sozinha.",
|
||||
"retry": "Tentar agora"
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
@@ -138,7 +145,8 @@
|
||||
"invalid": "O servidor não pôde aceitar esta solicitação.",
|
||||
"network": "Não foi possível alcançar o servidor. Verifique sua conexão.",
|
||||
"unavailable": "Os complementos não estão disponíveis agora. Tente de novo em instantes.",
|
||||
"unknownOutcome": "Não foi possível confirmar o resultado. Verifique o estado antes de tentar de novo."
|
||||
"unknownOutcome": "Não foi possível confirmar o resultado. Verifique o estado antes de tentar de novo.",
|
||||
"busy": "O servidor de complementos está ocupado e não respondeu a tempo. Tente de novo em instantes."
|
||||
},
|
||||
"installed": {
|
||||
"disabled": "Desativado",
|
||||
@@ -935,5 +943,25 @@
|
||||
"enableFailedTitle": "Não foi possível ativar {{name}}",
|
||||
"incompatibleTitle": "Não é possível ativar {{name}}: incompatível com este servidor",
|
||||
"noUpgrade": "Ainda não há uma versão mais nova na lista. Deixe-o desativado ou remova-o."
|
||||
},
|
||||
"operation": {
|
||||
"kind": {
|
||||
"install": "Instalando…",
|
||||
"upgrade": "Atualizando…",
|
||||
"uninstall": "Removendo…",
|
||||
"enable": "Iniciando…",
|
||||
"disable": "Parando…",
|
||||
"working": "Trabalhando…"
|
||||
},
|
||||
"label": "Em andamento: {{operation}}",
|
||||
"phase": {
|
||||
"queued": "aguardando",
|
||||
"downloading": "baixando e verificando",
|
||||
"backing-up": "copiando os dados",
|
||||
"installing": "instalando",
|
||||
"migrating": "migrando os dados",
|
||||
"starting": "iniciando",
|
||||
"working": "em andamento"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,13 @@
|
||||
"loadError": {
|
||||
"title": "附加组件暂时不可用。",
|
||||
"paused": "在它恢复之前,附加组件的页面和工具会暂停。"
|
||||
},
|
||||
"busy": {
|
||||
"upgrading": "附加组件服务器正忙(正在升级 {{names}})",
|
||||
"generic": "附加组件服务器正忙",
|
||||
"unreachable": "无法连接附加组件服务器",
|
||||
"reason": "显示的是最近一次已知的状态。本页会自动重试。",
|
||||
"retry": "立即重试"
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
@@ -138,7 +145,8 @@
|
||||
"invalid": "服务器无法接受此请求。",
|
||||
"network": "无法连接服务器,请检查网络。",
|
||||
"unavailable": "附加组件暂时不可用,请稍后重试。",
|
||||
"unknownOutcome": "无法确认结果。请先检查状态,再重试。"
|
||||
"unknownOutcome": "无法确认结果。请先检查状态,再重试。",
|
||||
"busy": "附加组件服务器正忙,未能及时响应。请稍后再试。"
|
||||
},
|
||||
"installed": {
|
||||
"disabled": "已关闭",
|
||||
@@ -935,5 +943,25 @@
|
||||
"enableFailedTitle": "无法打开 {{name}}",
|
||||
"incompatibleTitle": "无法打开 {{name}}:与此服务器不兼容",
|
||||
"noUpgrade": "目前还没有列出更新的版本。请保持关闭,或将其移除。"
|
||||
},
|
||||
"operation": {
|
||||
"kind": {
|
||||
"install": "正在安装…",
|
||||
"upgrade": "正在升级…",
|
||||
"uninstall": "正在移除…",
|
||||
"enable": "正在启动…",
|
||||
"disable": "正在停止…",
|
||||
"working": "处理中…"
|
||||
},
|
||||
"label": "进行中:{{operation}}",
|
||||
"phase": {
|
||||
"queued": "等待中",
|
||||
"downloading": "下载并校验",
|
||||
"backing-up": "备份数据",
|
||||
"installing": "安装",
|
||||
"migrating": "迁移数据",
|
||||
"starting": "启动",
|
||||
"working": "处理中"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../Icon'
|
||||
import StatusChip from '../ui/StatusChip'
|
||||
import Button from '../ui/Button'
|
||||
import { addedByEntry, hueOf, monogram, stateChip, versionLabel } from './addonState'
|
||||
import { addedByEntry, hueOf, monogram, operationOf, stateChip, versionLabel } from './addonState'
|
||||
import { installSteps } from './installSteps'
|
||||
import './addons.css'
|
||||
|
||||
@@ -22,6 +22,21 @@ export function StateChip({ app, className = '' }) {
|
||||
)
|
||||
}
|
||||
|
||||
// The operation the owner runs on an add-on now ("Upgrading… Downloading and
|
||||
// checking"). It is separate from the process state: the old generation can
|
||||
// keep running while the next one is fetched and checked.
|
||||
export function OperationChip({ app, className = '' }) {
|
||||
const { t } = useTranslation('addons')
|
||||
const op = operationOf(app)
|
||||
if (!op) return null
|
||||
const text = `${t(`operation.kind.${op.kind}`)} · ${t(`operation.phase.${op.phase}`)}`
|
||||
return (
|
||||
<span className={`ad-state ${className}`.trim()} data-tone="accent" data-testid="operation-chip" data-operation={op.kind} data-phase={op.phase} role="img" aria-label={t('operation.label', { operation: text })}>
|
||||
<StatusChip status="starting" label={text} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// The mark of an update the owner's check found. It is a separate chip from the
|
||||
// process state: an add-on can be running and have an update pending. With the
|
||||
// version known (administrators), the chip names it.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../Icon'
|
||||
import Button from '../ui/Button'
|
||||
import { apiUrl } from '../../utils/basePath'
|
||||
import { AppIcon, ApprovalChip, BuiltinIcon, StateChip, UpdateChip, VersionText } from './AddonBits'
|
||||
import { AppIcon, ApprovalChip, BuiltinIcon, OperationChip, StateChip, UpdateChip, VersionText } from './AddonBits'
|
||||
import AddonMenu from './AddonMenu'
|
||||
import { isLocalFile } from './fileInstall'
|
||||
import { addedByAddon, stateChip } from './addonState'
|
||||
@@ -37,7 +37,8 @@ export default function AddonRow({ tile, pinned, selected = false, attention = '
|
||||
<span className="ad-tile__head">
|
||||
<span className="ad-tile__title">{title}</span>
|
||||
{app && <StateChip app={app} className="ad-tile__state" />}
|
||||
{app && !waitingApproval && (app.update === 'update-pending' || updateVersion) && <UpdateChip version={updateVersion} />}
|
||||
{app && <OperationChip app={app} className="ad-tile__state" />}
|
||||
{app && !app.operation && !waitingApproval && (app.update === 'update-pending' || updateVersion) && <UpdateChip version={updateVersion} />}
|
||||
{app && waitingApproval && <ApprovalChip />}
|
||||
</span>
|
||||
{tile.description && <span className="ad-tile__desc">{tile.description}</span>}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Callout, EmptyState, LoadError, SearchField, Segmented, SkeletonRows } from '../ui'
|
||||
import { Button, Callout, EmptyState, LoadError, Notice, SearchField, Segmented, SkeletonRows } from '../ui'
|
||||
import { newOperationId, pollDelay } from '../catalog/catalogState'
|
||||
import { RegistryContext } from '../../addons/registryContext'
|
||||
import { navigationItems } from '../../addons/registryState'
|
||||
@@ -27,7 +27,7 @@ import { useBulkUpgrade } from '../../hooks/useBulkUpgrade'
|
||||
import { updateView, upgradeAvailability } from './updateState'
|
||||
import { upgradeFailureWords } from './updateReview'
|
||||
import FirstRun from './FirstRun'
|
||||
import { addedByAddon, attentionOf, filterByState, listState, sortByAttention, STATE_FILTERS } from './addonState'
|
||||
import { addedByAddon, attentionOf, filterByState, listState, loadLastKnown, saveLastKnown, sortByAttention, staleKind, STATE_FILTERS, upgradingTitles } from './addonState'
|
||||
import { describeError, describeOutcome } from './errors'
|
||||
import ApproveUpgradeDialog, { WaitingBanner } from './ApproveUpgradeDialog'
|
||||
import { pendingApprovals } from './hostGrantsState'
|
||||
@@ -38,9 +38,14 @@ import { useAddonActions } from './useAddonActions'
|
||||
// cadence, slower in a hidden tab and backing off while the owner is
|
||||
// unreachable. Start and stop are offered to administrators and the server
|
||||
// refuses everyone else whatever this view shows.
|
||||
// The tab keeps the last list it got for this person. A page loaded while the
|
||||
// owner is busy has nothing in memory, and an empty page would read as "no
|
||||
// add-ons"; the stored copy stands in for the list, marked stale.
|
||||
const browserStore = () => { try { return globalThis.sessionStorage || null } catch { return null } }
|
||||
|
||||
export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
const { t, i18n } = useTranslation('addons')
|
||||
const [storedState, setState] = useState({ status: 'loading', apps: [], error: null, stale: false })
|
||||
const [storedState, setState] = useState({ status: 'loading', apps: [], error: null, stale: false, staleReason: '' })
|
||||
const [reload, setReload] = useState(0)
|
||||
const reloadRef = useRef(() => {})
|
||||
const { hasFeature, isAdmin, user, sessionEpoch } = useAuth()
|
||||
@@ -52,7 +57,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
const actor = JSON.stringify([user?.id, sessionEpoch, isAdmin, canList])
|
||||
const scope = useRef(actor)
|
||||
scope.current = actor
|
||||
const state = storedState.actor === actor ? storedState : { status: 'loading', apps: [], error: null, stale: false }
|
||||
const state = storedState.actor === actor ? storedState : { status: 'loading', apps: [], error: null, stale: false, staleReason: '' }
|
||||
const ingest = registry?.ingest
|
||||
|
||||
// Poll with a timer chain: one request at a time, aborted on unmount. A
|
||||
@@ -71,17 +76,31 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
if (inflight) { queued = true; return }
|
||||
inflight = true
|
||||
try {
|
||||
const apps = await api.apps.list(ctl.signal)
|
||||
failures = 0
|
||||
const read = await api.apps.read(ctl.signal)
|
||||
const apps = read.apps
|
||||
// The server answered with its last copy: the owner did not. Ask
|
||||
// again with the same backoff as for a failed read.
|
||||
failures = read.stale ? failures + 1 : 0
|
||||
if (alive && current()) {
|
||||
setState({ actor, status: 'ready', apps, error: null, stale: false })
|
||||
ingest?.(apps)
|
||||
setState({ actor, status: 'ready', apps, error: null, stale: read.stale, staleReason: read.staleReason })
|
||||
if (!read.stale) {
|
||||
ingest?.(apps)
|
||||
const store = browserStore()
|
||||
if (store) saveLastKnown(store, actor, apps)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!alive || !current() || (error && error.name === 'AbortError')) return
|
||||
failures += 1
|
||||
// Keep what was on screen and say it may be out of date.
|
||||
setState(s => ({ actor, status: s.actor === actor && s.status === 'ready' ? 'ready' : 'error', apps: s.actor === actor ? s.apps : [], error, stale: true }))
|
||||
// Keep what was on screen and say it may be out of date. A page that
|
||||
// has nothing yet takes the copy this tab stored, so a refresh during
|
||||
// an upgrade does not show an empty page.
|
||||
const store = browserStore()
|
||||
const remembered = store ? loadLastKnown(store, actor) : null
|
||||
setState(s => {
|
||||
const kept = s.actor === actor && s.status === 'ready' ? s.apps : remembered
|
||||
return { actor, status: kept ? 'ready' : 'error', apps: kept || [], error, stale: true, staleReason: '' }
|
||||
})
|
||||
} finally {
|
||||
inflight = false
|
||||
}
|
||||
@@ -242,7 +261,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
},
|
||||
})
|
||||
|
||||
const firstRunNow = state.status === 'ready' && listState({ apps: state.apps, shownCount: tiles.length, query: '', isAdmin, canList }) === 'first-run'
|
||||
const firstRunNow = state.status === 'ready' && !state.stale && listState({ apps: state.apps, shownCount: tiles.length, query: '', isAdmin, canList }) === 'first-run'
|
||||
useEffect(() => {
|
||||
if (!onSummary) return
|
||||
onSummary(state.status === 'ready' ? { count: tiles.length, firstRun: firstRunNow } : null)
|
||||
@@ -287,17 +306,20 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
if (state.status === 'loading') {
|
||||
return <SkeletonRows count={3} label={t('apps.loading')} className="ad-skeleton" />
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
const kind = state.error && state.error.kind
|
||||
// An error, or a stale copy of an empty list, is never the empty state: the
|
||||
// page only says "no add-ons" when the owner answered with an empty list.
|
||||
if (state.status === 'error' || (state.stale && state.apps.length === 0)) {
|
||||
const loadError = state.error || { kind: 'busy' }
|
||||
const kind = loadError.kind
|
||||
return (
|
||||
<LoadError
|
||||
title={t('apps.loadError.title')}
|
||||
testId="addons-load-error"
|
||||
nothingChanged={false}
|
||||
onRetry={() => { setState({ status: 'loading', apps: [], error: null, stale: false }); setReload(n => n + 1) }}
|
||||
onRetry={() => { setState({ status: 'loading', apps: [], error: null, stale: false, staleReason: '' }); setReload(n => n + 1) }}
|
||||
>
|
||||
{[
|
||||
describeError(state.error, t),
|
||||
describeError(loadError, t),
|
||||
kind !== 'auth' && kind !== 'denied' && kind !== 'invalid' ? t('apps.loadError.paused') : '',
|
||||
t('common:ui.loadError.nothingChanged'),
|
||||
].filter(Boolean).join(' ')}
|
||||
@@ -315,9 +337,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
const showAttention = !firstRun && (attention.failed.length > 0 || attention.updates.length > 0 || approvals.length > 0)
|
||||
return (
|
||||
<div className="ad-apps">
|
||||
{state.stale && (
|
||||
<Callout tone="info" role="status" className="ad-callout">{t('apps.stale')}</Callout>
|
||||
)}
|
||||
{state.stale && <StaleNotice state={state} onRetry={() => reloadRef.current()} />}
|
||||
{Object.entries(actions.state).filter(([key, r]) => key.startsWith('uninstall:') && r.outcome && r.outcome !== 'completed' && !actions.unknown[key.split(':')[1]]).map(([key, r]) => (
|
||||
<Callout key={key} tone="error" role="alert" className="ad-callout" testId="remove-error">{describeOutcome(r.outcome, t, r.error)}</Callout>
|
||||
))}
|
||||
@@ -430,7 +450,7 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
isAdmin={isAdmin}
|
||||
upgrade={upgrade}
|
||||
variant="secondary"
|
||||
working={Boolean(actions.unknown[tile.id]) || upgrade.working(tile.id)}
|
||||
working={Boolean(actions.unknown[tile.id]) || upgrade.working(tile.id) || Boolean(tile.app.operation)}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
@@ -510,3 +530,26 @@ export default function AppsView({ api, adapter, onSummary, grants }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The banner over a list that may be out of date. It says why, names the
|
||||
// add-ons the owner is upgrading when the last list knew of it, and offers a
|
||||
// retry now; the page also asks again by itself, slower after each failure.
|
||||
function StaleNotice({ state, onRetry }) {
|
||||
const { t } = useTranslation('addons')
|
||||
const kind = staleKind(state)
|
||||
const names = upgradingTitles(state.apps)
|
||||
const title = kind === 'unreachable'
|
||||
? t('apps.busy.unreachable')
|
||||
: names.length > 0 ? t('apps.busy.upgrading', { names: names.join(', ') }) : t('apps.busy.generic')
|
||||
return (
|
||||
<Notice
|
||||
severity="warn"
|
||||
title={title}
|
||||
reason={t('apps.busy.reason')}
|
||||
fix={{ label: t('apps.busy.retry'), onClick: onRetry, testId: 'addons-stale-retry' }}
|
||||
className="ad-callout"
|
||||
testId="addons-stale"
|
||||
data-kind={kind}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -254,3 +254,62 @@ export function skillsOf(app) {
|
||||
const list = Array.isArray(app?.skills) ? app.skills : []
|
||||
return list.filter(s => s && typeof s.name === 'string').map(s => ({ name: s.name, replaces: s.shadows === s.name }))
|
||||
}
|
||||
|
||||
// ---- Operations on the owner ----------------------------------------------
|
||||
|
||||
const OPERATION_KINDS = new Set(['install', 'upgrade', 'uninstall', 'enable', 'disable'])
|
||||
const OPERATION_PHASES = new Set(['queued', 'downloading', 'backing-up', 'installing', 'migrating', 'starting', 'working'])
|
||||
|
||||
// The operation the owner runs on an add-on now, as the list reports it. A kind
|
||||
// or phase this page does not know is shown as plain work, never dropped:
|
||||
// the add-on is still being changed.
|
||||
export function operationOf(app) {
|
||||
const op = app && typeof app === 'object' ? app.operation : null
|
||||
if (!op || typeof op !== 'object') return null
|
||||
return {
|
||||
kind: OPERATION_KINDS.has(op.kind) ? op.kind : 'working',
|
||||
phase: OPERATION_PHASES.has(op.phase) ? op.phase : 'working',
|
||||
since: typeof op.since === 'string' ? op.since : '',
|
||||
}
|
||||
}
|
||||
|
||||
// The titles of the add-ons the owner is upgrading, for the busy banner.
|
||||
export function upgradingTitles(apps) {
|
||||
return (Array.isArray(apps) ? apps : [])
|
||||
.filter(a => a && a.operation && a.operation.kind === 'upgrade')
|
||||
.map(a => a.title || a.id)
|
||||
}
|
||||
|
||||
// Why the list on screen may be out of date: 'busy' (the owner did not answer
|
||||
// in time or the server sent its last copy), 'unreachable' or '' when it is
|
||||
// current.
|
||||
export function staleKind({ error, stale, staleReason }) {
|
||||
if (!stale) return ''
|
||||
if (error && error.kind === 'busy') return 'busy'
|
||||
if (staleReason === 'owner_busy') return 'busy'
|
||||
if (error || staleReason === 'owner_unavailable') return 'unreachable'
|
||||
return 'busy'
|
||||
}
|
||||
|
||||
// ---- The last list a browser got -------------------------------------------
|
||||
|
||||
const LAST_KEY = 'localai.addons.lastKnownApps.v1'
|
||||
|
||||
// A page loaded while the owner is busy has no list in memory. The last list
|
||||
// this browser tab got for the same person stands in for it, marked stale.
|
||||
// Storage can be absent or full: every call is allowed to do nothing.
|
||||
export function saveLastKnown(storage, actor, apps) {
|
||||
try {
|
||||
storage.setItem(LAST_KEY, JSON.stringify({ actor, apps, at: Date.now() }))
|
||||
} catch { /* the page works without it */ }
|
||||
}
|
||||
|
||||
export function loadLastKnown(storage, actor, maxAgeMs = 24 * 3600 * 1000, now = Date.now()) {
|
||||
try {
|
||||
const raw = JSON.parse(storage.getItem(LAST_KEY) || 'null')
|
||||
if (!raw || raw.actor !== actor || !Array.isArray(raw.apps) || !Number.isFinite(raw.at) || now - raw.at > maxAgeMs) return null
|
||||
return raw.apps
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export function describeError(error, t) {
|
||||
case 'invalid': return serverReason(error) || t('errors.invalid')
|
||||
case 'unknown_outcome': return t('errors.unknownOutcome')
|
||||
case 'network': return t('errors.network')
|
||||
case 'busy': return t('errors.busy')
|
||||
default: return t('errors.unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,9 @@ export function classifyResponse(status, body) {
|
||||
if (status === 503 && type === 'outcome_unknown') {
|
||||
return { kind: 'unknown_outcome', status, message, operationId: typeof err.operationId === 'string' ? err.operationId : '' }
|
||||
}
|
||||
// The owner is up but did not answer in time (an upgrade runs on it). The
|
||||
// page keeps what it shows and asks again; this is never "no add-ons".
|
||||
if (status === 503 && type === 'owner_busy') return { kind: 'busy', status, message, type }
|
||||
return { kind: 'unavailable', status, message }
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -115,8 +115,19 @@ export function createAddonsApi({ fetch: fetchImpl, base = p => p } = {}) {
|
||||
dispatchFile,
|
||||
apps: {
|
||||
async list(signal) {
|
||||
return (await api.apps.read(signal)).apps
|
||||
},
|
||||
// The list with the marks the server puts on it: stale is true when the
|
||||
// server answers with the last list it got from the owner (the owner is
|
||||
// busy or not reachable), so a page can say so and keep showing it.
|
||||
async read(signal) {
|
||||
const out = await call('/api/addons/apps', { signal })
|
||||
return Array.isArray(out?.apps) ? out.apps : []
|
||||
return {
|
||||
apps: Array.isArray(out?.apps) ? out.apps : [],
|
||||
stale: out?.stale === true,
|
||||
staleReason: typeof out?.staleReason === 'string' ? out.staleReason : '',
|
||||
staleSince: typeof out?.staleSince === 'string' ? out.staleSince : '',
|
||||
}
|
||||
},
|
||||
async start(id, { operationId, generation }, signal) {
|
||||
const out = await call(`/api/addons/apps/${encodeURIComponent(id)}/start`, { method: 'POST', body: { operationId, generation }, signal })
|
||||
|
||||
@@ -72,7 +72,7 @@ for (const switchUser of [false, true]) test(`installed records reject late admi
|
||||
let auth = { user: { id: 'admin' }, isAdmin: true, sessionEpoch: 1, hasFeature: () => true }
|
||||
const pending = deferred()
|
||||
let reads = 0
|
||||
const api = { apps: { list: async () => [{ id: 'notes', generation: 1 }] }, installed: () => { reads++; return pending.promise } }
|
||||
const api = { apps: { read: async () => ({ apps: [{ id: 'notes', generation: 1 }], stale: false, staleReason: '' }) }, installed: () => { reads++; return pending.promise } }
|
||||
const text = readFileSync(new URL('../../src/components/addons/AppsView.jsx', import.meta.url), 'utf8')
|
||||
// Full Apps body/effects; JSX is compiled, leaf hooks are external boundaries.
|
||||
const names = [...text.matchAll(/^import \{ ([^}]+) \} from/gm)].flatMap(m => m[1].split(',').map(n => n.trim()))
|
||||
|
||||
@@ -257,7 +257,7 @@ for (const accountSwitch of [false, true]) test(`Apps registry: late list cannot
|
||||
let registry = r.render()
|
||||
let auth = { user:{id:'alice'}, sessionEpoch:1, isAdmin:true, hasFeature:() => true }
|
||||
const reads = []
|
||||
const api = { apps:{ list:() => { const d = deferred(); reads.push(d); return d.promise } } }
|
||||
const api = { apps:{ read:() => { const d = deferred(); reads.push(d); return d.promise.then(apps => ({ apps, stale:false, staleReason:'' })) } } }
|
||||
const h = hookHarness('../../src/components/addons/AppsView.jsx', 'AppsView', {
|
||||
useAuth:() => auth, useContext:() => registry, RegistryContext:{}, useTranslation:() => ({t:x=>x,i18n:{}}),
|
||||
FEATURE_ADDONS:'addons', pollDelay:() => 1000, setTimeout:()=>1, clearTimeout:()=>{},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveTab, visibleTabs, resolveManage, stateChip, appActions, localizedText, toolSummary,
|
||||
shortDigest, updateStatus, formatBytes, rangeText, parseEntryKey, addedByAddon, addedByEntry, screenshotsOf, listState, featuredEntries,
|
||||
attentionOf, sortByAttention, filterByState, hueOf, monogram, versionLabel, skillShadowNotes, skillsOf,
|
||||
operationOf, upgradingTitles, staleKind, saveLastKnown, loadLastKnown,
|
||||
} from '../../src/components/addons/addonState.js'
|
||||
import { createAddonsApi, createAddonsCatalogAdapter, AddonsError } from '../../src/utils/addonsApi.js'
|
||||
import { runLifecycle, settleOperation, unavailableWait } from '../../src/components/addons/lifecycleFlow.js'
|
||||
@@ -615,3 +616,48 @@ test('addons: the details list the skills of an add-on and which replace a built
|
||||
[{ name: 'xlsx', replaces: true }, { name: 'office-tips', replaces: false }])
|
||||
assert.deepEqual(skillsOf({}), [])
|
||||
})
|
||||
|
||||
test('addons: a busy owner is its own kind of answer, and the list read carries the stale mark', async () => {
|
||||
assert.equal(classifyResponse(503, { error: { type: 'owner_busy', message: 'busy' } }).kind, 'busy')
|
||||
assert.equal(classifyResponse(503, { error: { type: 'owner_unavailable' } }).kind, 'unavailable')
|
||||
const busy = new AddonsError(classifyResponse(503, { error: { type: 'owner_busy' } }))
|
||||
assert.match(describeError(busy, key => key), /errors\.busy/)
|
||||
const api = createAddonsApi({ fetch: async () => json(200, { apps: [{ id: 'notes' }], stale: true, staleReason: 'owner_busy', staleSince: '2026-11-04T17:18:00Z' }) })
|
||||
assert.deepEqual(await api.apps.read(), { apps: [{ id: 'notes' }], stale: true, staleReason: 'owner_busy', staleSince: '2026-11-04T17:18:00Z' })
|
||||
assert.deepEqual((await api.apps.list()).map(a => a.id), ['notes'])
|
||||
const plain = createAddonsApi({ fetch: async () => json(200, { apps: [] }) })
|
||||
assert.equal((await plain.apps.read()).stale, false)
|
||||
})
|
||||
|
||||
test('addons: an error is never an empty list, and the page names what the owner is upgrading', () => {
|
||||
const apps = [
|
||||
{ id: 'notes', title: 'Notes', operation: { kind: 'upgrade', phase: 'downloading', since: '2026-11-04T17:18:00Z' } },
|
||||
{ id: 'sandbox', title: 'Sandbox', operation: { kind: 'upgrade', phase: 'starting' } },
|
||||
{ id: 'plain', title: 'Plain' },
|
||||
]
|
||||
assert.deepEqual(upgradingTitles(apps), ['Notes', 'Sandbox'])
|
||||
assert.deepEqual(upgradingTitles(null), [])
|
||||
assert.deepEqual(operationOf(apps[0]), { kind: 'upgrade', phase: 'downloading', since: '2026-11-04T17:18:00Z' })
|
||||
assert.equal(operationOf(apps[2]), null)
|
||||
// A kind or phase this page does not know is still work in progress.
|
||||
assert.deepEqual(operationOf({ operation: { kind: 'defrag', phase: 'later' } }), { kind: 'working', phase: 'working', since: '' })
|
||||
assert.equal(staleKind({ stale: false, error: new Error('x') }), '')
|
||||
assert.equal(staleKind({ stale: true, error: { kind: 'busy' } }), 'busy')
|
||||
assert.equal(staleKind({ stale: true, staleReason: 'owner_busy' }), 'busy')
|
||||
assert.equal(staleKind({ stale: true, error: { kind: 'network' } }), 'unreachable')
|
||||
assert.equal(staleKind({ stale: true, staleReason: 'owner_unavailable' }), 'unreachable')
|
||||
})
|
||||
|
||||
test('addons: the last list of a tab stands in for a page loaded while the owner is busy', () => {
|
||||
const data = new Map()
|
||||
const store = { getItem: k => (data.has(k) ? data.get(k) : null), setItem: (k, v) => { data.set(k, v) } }
|
||||
assert.equal(loadLastKnown(store, 'a'), null)
|
||||
saveLastKnown(store, 'a', [{ id: 'notes' }])
|
||||
assert.deepEqual(loadLastKnown(store, 'a'), [{ id: 'notes' }])
|
||||
assert.equal(loadLastKnown(store, 'someone-else'), null)
|
||||
assert.equal(loadLastKnown(store, 'a', 1000, Date.now() + 5000), null)
|
||||
// Storage that throws is the same as no storage.
|
||||
const broken = { getItem() { throw new Error('blocked') }, setItem() { throw new Error('full') } }
|
||||
saveLastKnown(broken, 'a', [])
|
||||
assert.equal(loadLastKnown(broken, 'a'), null)
|
||||
})
|
||||
Reference in new issue
Block a user