Skip to content

Commit 43ccbd5

Browse files
Merge pull request #361 from appdevforall/fix/ADFA-4954-kolibri-dashboard-defects
ADFA-4954 fix(kolibri): four defects the device found, 1.2.1
2 parents 7ed6aec + f285b2d commit 43ccbd5

8 files changed

Lines changed: 310 additions & 65 deletions

File tree

‎static/dashboard/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ One line per version, newest first. Every REST-facing change bumps the version i
44
(the app surfaces it via `/system/dashboard/update-check` and the "Update available" pill), so this
55
file is the human record of what each bump enables. Keep entries short: `version - change (TICKET)`.
66

7+
- **1.2.1** - Four Kolibri defects found by testing against a real device. `/kolibri/estimate` now reports free space (the call was missing the mandatory `?path=Content` and had been failing silently since it was written) and answers **409** with a readable message for a channel that is not installed, instead of letting Kolibri return a bare 500; `/kolibri/catalog` reports real sizes and resource counts (it was reading Studio's field names on an endpoint that uses Kolibri's); a failed import now carries the cause instead of just the exception class name. Also finishes the English pass: `routes.ts` was the last file the 1.1.6 sweep missed — REST-facing too, since one response string changes (`POST /credentials/kolibri` on 401). (ADFA-4954)
78
- **1.2.0** - Self-update cutover baseline: no endpoint change, but from this version the app updates the dash-node core **live over REST** (`POST /system/dashboard/rebuild`, the blue-green rebuild from ADFA-5011) instead of a proot rebuild. Installs on < 1.2.0 still use the proot path to reach 1.2.0; 1.2.0+ update to 1.2.1+ via REST. (ADFA-5051)
89
- **1.1.6** - Kolibri modules now in English. REST-facing because the diagnostic text changed: the `blockers[]` strings from `GET /kolibri/ready` and the `error` text on Kolibri jobs. Shapes, status codes and field names are unchanged.
910
- **1.1.5** - Credentials: `POST /credentials/calibre` now validates against live Calibre-Web (401 on reject, save-unverified when the service is down); `GET /credentials/:service` returns the default password only while still at the factory default, for full form prefill. (ADFA-5044)

‎static/dashboard/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎static/dashboard/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dashboard-console",
3-
"version": "1.2.0",
3+
"version": "1.2.1",
44
"description": "",
55
"main": "index.js",
66
"scripts": {

‎static/dashboard/routes.ts‎

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { parseBox, parseEstimate } from './sockets/maps.socket';
1414
import {
1515
preflight, listInstalledChannels, browseRemoteChannels, resolveIdentifier,
1616
browseChannelTree, estimateSelection, deleteChannel, getKolibriTask, verifyCredentials,
17+
ChannelNotInstalledError,
1718
} from './sockets/kolibri.query';
1819
import { checkReadiness, KolibriAuthError, KolibriApiError, login as kolibriLogin } from './sockets/kolibri.session';
1920
import {
@@ -359,31 +360,43 @@ apiRouter.get('/system/dashboard/update-check', async (_req: Request, res: Respo
359360
}
360361
});
361362

362-
// --- Kolibri: readiness, catálogo y selección (ADFA-4949) -------------------------
363-
// Consultas directas (no-job). La descarga en sí es un job durable
364-
// (POST /kolibri/download), que sale gratis al añadir 'kolibri' a VALID_TYPES.
363+
// --- Kolibri: readiness, catalogue and selection (ADFA-4949) ----------------------
364+
// Direct (non-job) queries. The download itself is a durable job
365+
// (POST /kolibri/download), which comes free from adding 'kolibri' to VALID_TYPES.
365366
//
366-
// Nota de puertos y prefijos: nginx expone Kolibri en /kolibri/ y además le enruta
367-
// /api/, /content/, /device/ y /learn/ en el :8085 compartido. Nosotros hablamos
368-
// con Kolibri directamente en 127.0.0.1:8009 desde dentro, así que no hay colisión.
367+
// A note on ports and prefixes: nginx exposes Kolibri at /kolibri/ and also routes
368+
// /api/, /content/, /device/ and /learn/ to it on the shared :8085. We talk to
369+
// Kolibri directly on 127.0.0.1:8009 from inside, so there is no collision.
369370

370-
/** Traduce un KolibriAuthError al status HTTP correcto. */
371+
/** Translates a Kolibri failure into the right HTTP status. */
371372
function authStatus(e: unknown): number {
372373
if (e instanceof KolibriAuthError) {
373374
switch (e.reason) {
374-
case 'unreachable': return 503; // el servicio no está listo
375+
case 'unreachable': return 503; // the service is not ready
375376
case 'credentials': return 401;
376377
case 'permission': return 403;
377378
default: return 502;
378379
}
379380
}
380-
// Kolibri respondió, pero con error: propagamos su semántica en lugar de un 500.
381+
// Kolibri answered, but with an error. 502 in every case that is not an auth
382+
// one: whatever Kolibri's status was, from here it is an upstream we could not
383+
// get a usable answer from, and echoing its 4xx would blame the app's caller
384+
// for a request the app itself composed. (This used to read
385+
// `e.status >= 500 ? 502 : 502` — both arms the same, so the distinction it
386+
// implied never existed.)
381387
if (e instanceof KolibriApiError) {
382388
if (e.status === 401 || e.status === 403) return e.status;
383-
return e.status >= 500 ? 502 : 502; // upstream en mal estado
389+
return 502;
384390
}
385-
// fetch aborta por timeout (AbortError) o no conecta (TypeError). Eso es
386-
// "Kolibri no está listo", no "nuestro servidor se rompió": 503, no 500.
391+
// The request was well formed but a precondition is not met — the channel is
392+
// not on the device. That is the caller's state, not a server fault, so it
393+
// must not be a 500: the readable message would arrive labelled as an
394+
// internal error, which is as misleading as the bare 500 it replaced.
395+
if (e instanceof ChannelNotInstalledError) {
396+
return 409;
397+
}
398+
// fetch aborts on timeout (AbortError) or fails to connect (TypeError). That is
399+
// "Kolibri is not ready", not "our server broke": 503, not 500.
387400
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TimeoutError'
388401
|| e instanceof TypeError)) {
389402
return 503;
@@ -392,19 +405,19 @@ function authStatus(e: unknown): number {
392405
}
393406

394407
// El "gate" de arranque: mientras ready sea false, no tiene sentido lanzar jobs.
395-
// Nunca lanza: siempre devuelve un diagnóstico con los bloqueadores en claro.
408+
// Never throws: it always returns a diagnosis with the blockers spelled out.
396409
apiRouter.get('/kolibri/ready', async (_req: Request, res: Response): Promise<void> => {
397410
try {
398411
const readiness = await checkReadiness();
399-
// 200 siempre: es un endpoint de estado, no una operación. El cliente mira
412+
// Always 200: this is a status endpoint, not an operation. The client reads
400413
// el campo `ready`, no el status.
401414
res.json(readiness);
402415
} catch (e: any) {
403416
res.status(500).json({ ready: false, blockers: [e?.message || 'readiness failed'] });
404417
}
405418
});
406419

407-
// Diagnóstico ampliado: readiness + estado local (canales, bytes en disco, rutas).
420+
// Extended diagnosis: readiness plus local state (channels, bytes on disk, paths).
408421
apiRouter.get('/kolibri/preflight', async (_req: Request, res: Response): Promise<void> => {
409422
try {
410423
res.json(await preflight());
@@ -413,8 +426,8 @@ apiRouter.get('/kolibri/preflight', async (_req: Request, res: Response): Promis
413426
}
414427
});
415428

416-
// Qué hay ya en el dispositivo. Lectura local en readonly: no necesita ni sesión
417-
// ni que Kolibri esté arriba.
429+
// What is already on the device. A local readonly read: it needs neither a session
430+
// nor Kolibri to be up.
418431
apiRouter.get('/kolibri/channels', (_req: Request, res: Response): void => {
419432
try {
420433
res.json(listInstalledChannels());
@@ -423,7 +436,7 @@ apiRouter.get('/kolibri/channels', (_req: Request, res: Response): void => {
423436
}
424437
});
425438

426-
// Catálogo remoto para el selector del wizard. ?keyword= y ?language= opcionales.
439+
// Remote catalogue for the wizard's picker. ?keyword= and ?language= are optional.
427440
apiRouter.get('/kolibri/catalog', async (req: Request, res: Response): Promise<void> => {
428441
try {
429442
res.json(await browseRemoteChannels({
@@ -446,10 +459,10 @@ apiRouter.get('/kolibri/resolve/:identifier', async (req: Request, res: Response
446459
}
447460
});
448461

449-
// Un nivel del árbol del canal, para elegir subárboles.
462+
// One level of the channel tree, for choosing subtrees.
450463
// PRECONDICIÓN: los metadatos del canal deben estar ya en la base local, o sea que
451464
// hay que haber importado el canal antes. Es el mismo flujo que la UI de Kolibri:
452-
// primero los metadatos (MB), luego la selección del contenido (GB).
465+
// metadata first (MB), then the content selection (GB).
453466
apiRouter.get('/kolibri/tree/:channelId', async (req: Request, res: Response): Promise<void> => {
454467
try {
455468
const nodeId = req.query.nodeId ? String(req.query.nodeId) : undefined;
@@ -459,7 +472,7 @@ apiRouter.get('/kolibri/tree/:channelId', async (req: Request, res: Response): P
459472
}
460473
});
461474

462-
// Tamaño exacto de una selección + espacio libre, para el paso de consentimiento.
475+
// What a selection still has to transfer, plus free space, for the consent step.
463476
// Body: { channelId, nodeIds?, excludeNodeIds? }
464477
apiRouter.post('/kolibri/estimate', async (req: Request, res: Response): Promise<void> => {
465478
const body = req.body as { channelId?: unknown; nodeIds?: unknown; excludeNodeIds?: unknown };
@@ -477,7 +490,7 @@ apiRouter.post('/kolibri/estimate', async (req: Request, res: Response): Promise
477490
});
478491

479492
// Borrar un canal. Devuelve el id del job de Kolibri, no del motor local: es una
480-
// operación corta y no merece un job durable.
493+
// a short operation and does not warrant a durable job.
481494
apiRouter.post('/kolibri/delete', async (req: Request, res: Response): Promise<void> => {
482495
const body = req.body as { channelId?: unknown; channelName?: unknown };
483496
const channelId = String(body?.channelId ?? '').trim();
@@ -493,7 +506,7 @@ apiRouter.post('/kolibri/delete', async (req: Request, res: Response): Promise<v
493506
});
494507

495508
// Estado de una tarea lanzada directamente en Kolibri (hoy solo el borrado). Sin
496-
// esto, /kolibri/delete devolvía un id que ningún endpoint sabía consultar.
509+
// this, /kolibri/delete returned an id no endpoint knew how to query.
497510
apiRouter.get('/kolibri/task/:id', async (req: Request, res: Response): Promise<void> => {
498511
try {
499512
res.json(await getKolibriTask(String(req.params.id)));
@@ -504,8 +517,8 @@ apiRouter.get('/kolibri/task/:id', async (req: Request, res: Response): Promise<
504517
});
505518

506519
// --- Credenciales de servicios (ADFA-4949) ---------------------------------------
507-
// Permite al webview actualizar usuario/contraseña sin recompilar. La contraseña
508-
// NUNCA se devuelve: el GET solo dice qué usuario hay y de dónde sale.
520+
// Lets the webview update the username/password without a rebuild. The password is
521+
// NEVER returned: the GET only says which user is set and where it came from.
509522

510523
apiRouter.get('/credentials/:service', (req: Request, res: Response): void => {
511524
const service = String(req.params.service);
@@ -517,8 +530,8 @@ apiRouter.get('/credentials/:service', (req: Request, res: Response): void => {
517530
}
518531
});
519532

520-
// Valida ANTES de persistir: así el error aparece en el formulario donde se cometió,
521-
// no media hora después en una descarga fallida.
533+
// Validates BEFORE persisting, so the error surfaces in the form where it was made
534+
// rather than half an hour later in a failed download.
522535
// 401 → credenciales rechazadas (no se guarda nada)
523536
// 403 → autentica pero no puede gestionar contenido (no se guarda nada)
524537
// 503 → Kolibri no responde; la capa de arranque debe resolverlo primero
@@ -579,7 +592,7 @@ apiRouter.post('/credentials/:service', async (req: Request, res: Response): Pro
579592
try {
580593
const check = await verifyCredentials(username, password);
581594
if (!check.canManageContent) {
582-
// Distinguir esto de "contraseña mala" ahorra horas de diagnóstico: el
595+
// Telling this apart from "wrong password" saves hours of diagnosis: the
583596
// usuario existe y la clave es correcta, pero no puede importar contenido.
584597
res.status(403).json({
585598
error: `'${username}' autentica pero no tiene permiso para gestionar contenido`,
@@ -593,14 +606,14 @@ apiRouter.post('/credentials/:service', async (req: Request, res: Response): Pro
593606
const status = authStatus(e);
594607
res.status(status).json({
595608
error: status === 401
596-
? 'Kolibri rechazó estas credenciales'
609+
? 'Kolibri rejected these credentials'
597610
: (e?.message || 'verification failed'),
598611
saved: false,
599612
});
600613
}
601614
});
602615

603-
// Volver al valor de fábrica (o a la variable de entorno, si está puesta).
616+
// Back to the factory value (or to the environment variable, if one is set).
604617
apiRouter.delete('/credentials/:service', (req: Request, res: Response): void => {
605618
const service = String(req.params.service);
606619
if (!isServiceName(service)) { res.status(404).json({ error: 'unknown service' }); return; }

‎static/dashboard/sockets/kolibri.exec.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ import {
2323
KolibriSession, KolibriAuthError, KolibriApiError, STUDIO_URL,
2424
} from './kolibri.session';
2525
import {
26-
buildTaskPayload, mapPercent, mapPhase, normalizeUuid, overallPercent,
27-
sampleSpeed, PRE_RUN_STATES, TERMINAL_STATES,
26+
buildTaskPayload, failureMessage, mapPercent, mapPhase, normalizeUuid,
27+
overallPercent, sampleSpeed, PRE_RUN_STATES, TERMINAL_STATES,
2828
} from './kolibri.map';
2929

3030
/** Positive integers from the environment, with a default. Adjustable in
@@ -368,7 +368,10 @@ async function pollKolibriJob(
368368
// Cleared on ALL terminal paths, not only on success: otherwise the
369369
// failed jobs pile up in Kolibri's queue.
370370
if (job.status === 'FAILED') {
371-
const detail = job.exception || 'no detail';
371+
// job.exception is only the class name — a bad channel id reports
372+
// a bare "HTTPError". failureMessage digs the real line out of the
373+
// traceback so the operator is told what actually went wrong.
374+
const detail = failureMessage(job.exception, job.traceback);
372375
ctx.log(`FAILURE in Kolibri: ${detail}`);
373376
if (job.traceback) ctx.log(job.traceback.split('\n').slice(-6).join('\n'));
374377
await clearInKolibri();

‎static/dashboard/sockets/kolibri.map.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,91 @@ export function buildTaskPayload(item: TaskPayloadInput): Record<string, unknown
127127
return payload;
128128
}
129129

130+
/**
131+
* The most informative message available for a failed Kolibri task.
132+
*
133+
* Kolibri's `exception` field is the class name alone — a job that failed on a
134+
* channel that does not exist reports `HTTPError`, which tells the reader
135+
* nothing. The detail is in the last line of `traceback`, in Python's usual
136+
* `module.ExceptionType: message` form, so that line is preferred when it says
137+
* more than the class name already did. The module prefix is dropped: an
138+
* operator does not need `requests.exceptions.` to understand the problem.
139+
*
140+
* Falls back to the class name, and then to a plain marker, so the caller always
141+
* has something to put in front of a person.
142+
*/
143+
export function failureMessage(exception?: string | null,
144+
traceback?: string | null): string {
145+
const cls = (exception || '').trim();
146+
147+
const lastLine = (traceback || '')
148+
.split('\n')
149+
.map((l) => l.trim())
150+
.filter((l) => l.length > 0)
151+
.pop() || '';
152+
153+
// "requests.exceptions.HTTPError: 404 Client Error" -> "HTTPError: 404 Client Error"
154+
const match = /^([\w.]*?)(\w+(?:Error|Exception|Warning)):\s*(.+)$/.exec(lastLine);
155+
if (match && match[3]) {
156+
return `${match[2]}: ${match[3]}`;
157+
}
158+
159+
// A traceback line that carries detail without matching the usual shape is
160+
// still better than a bare class name, as long as it is not the class name.
161+
if (lastLine && lastLine !== cls) {
162+
return lastLine;
163+
}
164+
return cls || 'no detail';
165+
}
166+
167+
/** A channel as the picker needs it, whichever endpoint it came from. */
168+
export interface RemoteChannel {
169+
id: string;
170+
name: string;
171+
description: string;
172+
version: number | null;
173+
language: string | null;
174+
totalResources: number | null;
175+
publishedSize: number | null;
176+
}
177+
178+
/**
179+
* Reads a channel row into {@link RemoteChannel}.
180+
*
181+
* Lives here rather than next to the fetch because of the trap it encapsulates:
182+
* **the same channel arrives under two different sets of field names.** Studio's
183+
* own API says `total_resource_count` and `published_size`; Kolibri's
184+
* `/api/content/remotechannel/` proxy renames them to `total_resources` and
185+
* `total_file_size` on the way through. Reading only Studio's names against the
186+
* proxy silently yields `null` for both — which is exactly what shipped, and
187+
* what a device test caught. Both spellings are accepted, Kolibri's first,
188+
* because that is the endpoint the box actually calls.
189+
*
190+
* Pure: no network here, so the renaming is covered by a unit test and cannot
191+
* regress unnoticed.
192+
*/
193+
export function toRemoteChannel(row: Record<string, unknown>): RemoteChannel {
194+
const num = (v: unknown): number | null =>
195+
typeof v === 'number' ? v : (typeof v === 'string' && v.trim() !== '' ? Number(v) : null);
196+
const first = (...vs: unknown[]): number | null => {
197+
for (const v of vs) {
198+
const n = num(v);
199+
if (n !== null) return n;
200+
}
201+
return null;
202+
};
203+
return {
204+
id: String(row.id ?? ''),
205+
name: String(row.name ?? ''),
206+
description: String(row.description ?? ''),
207+
version: num(row.version),
208+
language: typeof row.lang_code === 'string' ? row.lang_code
209+
: (typeof row.language === 'string' ? row.language : null),
210+
totalResources: first(row.total_resources, row.total_resource_count),
211+
publishedSize: first(row.total_file_size, row.published_size),
212+
};
213+
}
214+
130215
/** Bytes per second from two samples of `transferred_file_size`.
131216
* Returns null when there was no progress, so a 0 does not overwrite the previous
132217
* reading. */

0 commit comments

Comments
 (0)