Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions api/src/widget-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { fetchChecked, fetchUnchecked, SsrfBlockedError } = require('./proxy');
const { parsePrometheus } = require('./parse-prometheus');
const { cpuSample, ramPercent, cpuTemp, diskStats, procCount, uptimeSeconds } = require('./metrics');
const { getRegistry, WIDGETS_PATH } = require('./widgets');
const { translatorFor } = require('./widget-i18n');
const { preserveWidgetSecrets } = require('./widget-secrets');
const { widgetConfigMatchesSaved, leavesStoredSecretBlank, RETYPE_MESSAGE } = require('./secret-scope');
const { dispatchProvider } = require('./provider-dispatch');
Expand Down Expand Up @@ -121,6 +122,18 @@ on('GET', '/api/widget-data/:id', async (req, res) => {

/* Config-time "Fetch" for a select field declared with optionsFrom. The id is
'__preview__' before the first save. */
function labelOptions(body, entry, widget) {
if (!body || !Array.isArray(body.options) || !body.options.some(o => o && o.labelKey)) return body;
const translate = translatorFor(entry.dir, widget, loadConfig().settings?.language || 'en');
const options = body.options.map(o => {
if (!o || typeof o.labelKey !== 'string') return o;
const { labelKey, ...rest } = o;
const found = translate(labelKey);
return found === labelKey ? rest : { ...rest, label: found };
});
return { ...body, options };
}

on('POST', '/api/widget-options/:id', async (req, res) => {
if (!checkOrigin(req, res)) return;
const limited = rateLimit(getIp(req), 'widget-options', LIMITS.WIDGET_OPTIONS.max, LIMITS.WIDGET_OPTIONS.windowMs);
Expand Down Expand Up @@ -158,7 +171,7 @@ on('POST', '/api/widget-options/:id', async (req, res) => {
body.row,
true,
);
json(res, out.status, out.body);
json(res, out.status, labelOptions(out.body, entry, body.widgetType));
} catch (e) {
if (e instanceof SsrfBlockedError) return fail(res, e, { status: e.status });
if (withheld)
Expand All @@ -168,4 +181,4 @@ on('POST', '/api/widget-options/:id', async (req, res) => {
}
});

module.exports = { getWidgetData, normalizeBase, dataFnContext, resolveRow };
module.exports = { getWidgetData, normalizeBase, dataFnContext, resolveRow, labelOptions };
16 changes: 11 additions & 5 deletions api/test/widget-catalogs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const widgets = fs
.filter(d => d.isDirectory() && fs.existsSync(path.join(WIDGETS_DIR, d.name, 'widget.json')))
.map(d => d.name);

/* A counted message is one key per plural category, and each language uses its
own categories. Keys are compared by base name. */
const PLURAL = /_(zero|one|two|few|many|other)$/;
const baseKeys = cat => [...new Set(Object.keys(cat).map(k => k.replace(PLURAL, '')))].sort();

const hasCatalog = w => fs.existsSync(path.join(WIDGETS_DIR, w, 'i18n', 'en.json'));
const readCatalog = (w, lang) => JSON.parse(fs.readFileSync(path.join(WIDGETS_DIR, w, 'i18n', lang + '.json'), 'utf8'));

Expand All @@ -48,12 +53,12 @@ test('at least one bundled widget ships a catalog, or these tests prove nothing'

for (const widget of widgets.filter(hasCatalog)) {
test(`${widget}: every language has exactly the English keys`, () => {
const en = Object.keys(readCatalog(widget, 'en')).sort();
const en = baseKeys(readCatalog(widget, 'en'));
for (const lang of LANGUAGES.filter(l => l !== 'en')) {
const file = path.join(WIDGETS_DIR, widget, 'i18n', lang + '.json');
assert.ok(fs.existsSync(file), `${widget} is missing ${lang}.json`);
assert.deepEqual(
Object.keys(readCatalog(widget, lang)).sort(),
baseKeys(readCatalog(widget, lang)),
en,
`${widget}/${lang}.json does not have the same keys as en.json`,
);
Expand Down Expand Up @@ -82,9 +87,10 @@ for (const widget of widgets.filter(hasCatalog)) {
const en = readCatalog(widget, 'en');
for (const lang of LANGUAGES.filter(l => l !== 'en')) {
const other = readCatalog(widget, lang);
for (const [k, v] of Object.entries(en)) {
const want = (v.match(/\{[a-zA-Z0-9_]+\}/g) || []).sort();
const got = ((other[k] || '').match(/\{[a-zA-Z0-9_]+\}/g) || []).sort();
for (const [k, v] of Object.entries(other)) {
const source = en[k] ?? en[`${k.replace(PLURAL, '')}_other`] ?? '';
const want = (source.match(/\{[a-zA-Z0-9_]+\}/g) || []).sort();
const got = (v.match(/\{[a-zA-Z0-9_]+\}/g) || []).sort();
assert.deepEqual(got, want, `${widget}/${lang}.json: ${k} does not carry the same placeholders`);
}
}
Expand Down
46 changes: 46 additions & 0 deletions api/test/widget-options-labels.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const path = require('node:path');

process.env.WIDGETS_PATH = path.join(__dirname, '../../ui/widgets');
const { tmpDir } = require('../test-support/tmp');
process.env.CONFIG_PATH = path.join(tmpDir('option-labels'), 'apps.json');

const { test, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const { saveConfig } = require('../src/config');
const { labelOptions } = require('../src/widget-data');
const { _resetCache } = require('../src/widget-i18n');

const entry = { dir: path.join(__dirname, '../../ui/widgets/system-summary') };
const body = () => ({
options: [
{ value: '*', label: 'All interfaces', labelKey: 'network.interface.all' },
{ value: 'eth0', label: 'eth0' },
],
});

beforeEach(() => _resetCache());

test('an option named by key takes its label from the configured language', () => {
saveConfig({ items: [], settings: { language: 'de' } });
const out = labelOptions(body(), entry, 'system-summary');
assert.deepEqual(out.options, [
{ value: '*', label: 'Alle Schnittstellen' },
{ value: 'eth0', label: 'eth0' },
]);
});

test('an option whose key no catalog has keeps its English label', () => {
saveConfig({ items: [], settings: { language: 'fr' } });
const out = labelOptions(
{ options: [{ value: 'x', label: 'Fallback', labelKey: 'no.such.key' }] },
entry,
'system-summary',
);
assert.deepEqual(out.options, [{ value: 'x', label: 'Fallback' }]);
});

test('a reply without keyed options passes through untouched', () => {
const plain = { options: [{ value: 'a', label: 'a' }] };
assert.equal(labelOptions(plain, entry, 'system-summary'), plain);
assert.equal(labelOptions(null, entry, 'system-summary'), null);
});
2 changes: 1 addition & 1 deletion ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,6 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div></div>
</div>

<script type="module" src="/js/admin.js?v=193521a6"></script>
<script type="module" src="/js/admin.js?v=4c1146ce"></script>
</body>
</html>
15 changes: 12 additions & 3 deletions ui/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
},
"type": {
"addNew": "Neu hinzufügen",
"addNewNamed": "Neu hinzufügen: {name}",
"app": "App",
"widget": "Widget",
"folder": "Ordner",
Expand Down Expand Up @@ -295,8 +296,8 @@
"goToPage": "Zu Seite {page} von {total}",
"appsAndFolders": "Apps & Ordner",
"searchResults": "Suchergebnisse",
"result": "Ergebnis",
"results": "Ergebnisse",
"resultCount_one": "{count} Ergebnis",
"resultCount_other": "{count} Ergebnisse",
"apiDownTitle": "Keine Verbindung zur Dashboard-API",
"apiDownSub": "Stellen Sie sicher, dass der API-Container läuft",
"retry": "Erneut versuchen",
Expand Down Expand Up @@ -400,7 +401,15 @@
"status": {
"needsAttention": "Status: benötigt Aufmerksamkeit",
"healthy": "Status: in Ordnung",
"pending": "{count} ausstehend",
"pending_one": "{count} ausstehend",
"pending_other": "{count} ausstehend",
"pendingValue": "{value} ausstehend",
"containerCreated": "Container erstellt",
"containerRestarting": "Container startet neu",
"containerRemoving": "Container wird entfernt",
"containerPaused": "Container pausiert",
"containerExited": "Container gestoppt",
"containerDead": "Container ausgefallen",
"stale": "(möglicherweise veraltet)",
"moreValues_one": "{count} weiteres Badge",
"moreValues_other": "{count} weitere Badges",
Expand Down
15 changes: 12 additions & 3 deletions ui/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
},
"type": {
"addNew": "Add New",
"addNewNamed": "Add New: {name}",
"app": "App",
"widget": "Widget",
"folder": "Folder",
Expand Down Expand Up @@ -295,8 +296,8 @@
"goToPage": "Go to page {page} of {total}",
"appsAndFolders": "Apps & Folders",
"searchResults": "Search results",
"result": "result",
"results": "results",
"resultCount_one": "{count} result",
"resultCount_other": "{count} results",
"apiDownTitle": "Could not connect to dashboard API",
"apiDownSub": "Make sure the API container is running",
"retry": "Retry",
Expand Down Expand Up @@ -400,7 +401,15 @@
"status": {
"needsAttention": "Status: needs attention",
"healthy": "Status: healthy",
"pending": "{count} pending",
"pending_one": "{count} pending",
"pending_other": "{count} pending",
"pendingValue": "{value} pending",
"containerCreated": "Container created",
"containerRestarting": "Container restarting",
"containerRemoving": "Container being removed",
"containerPaused": "Container paused",
"containerExited": "Container stopped",
"containerDead": "Container failed",
"stale": "(may be out of date)",
"moreValues_one": "{count} more badge",
"moreValues_other": "{count} more badges",
Expand Down
17 changes: 14 additions & 3 deletions ui/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
},
"type": {
"addNew": "Añadir nuevo",
"addNewNamed": "Añadir nuevo: {name}",
"app": "Aplicación",
"widget": "Widget",
"folder": "Carpeta",
Expand Down Expand Up @@ -300,8 +301,9 @@
"goToPage": "Ir a la página {page} de {total}",
"appsAndFolders": "Aplicaciones y carpetas",
"searchResults": "Resultados de la búsqueda",
"result": "resultado",
"results": "resultados",
"resultCount_one": "{count} resultado",
"resultCount_many": "{count} de resultados",
"resultCount_other": "{count} resultados",
"apiDownTitle": "No se pudo conectar con la API del panel",
"apiDownSub": "Comprueba que el contenedor de la API esté en ejecución",
"retry": "Reintentar",
Expand Down Expand Up @@ -406,7 +408,16 @@
"status": {
"needsAttention": "Estado: necesita atención",
"healthy": "Estado: correcto",
"pending": "{count} pendientes",
"pending_one": "{count} pendiente",
"pending_many": "{count} pendientes",
"pending_other": "{count} pendientes",
"pendingValue": "{value} pendientes",
"containerCreated": "Contenedor creado",
"containerRestarting": "Contenedor reiniciándose",
"containerRemoving": "Eliminando el contenedor",
"containerPaused": "Contenedor en pausa",
"containerExited": "Contenedor detenido",
"containerDead": "Contenedor con errores",
"stale": "(puede estar desactualizado)",
"moreValues_one": "{count} insignia más",
"moreValues_many": "{count} insignias más",
Expand Down
15 changes: 12 additions & 3 deletions ui/i18n/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
},
"type": {
"addNew": "افزودن جدید",
"addNewNamed": "افزودن جدید: {name}",
"app": "برنامه",
"widget": "ویجت",
"folder": "پوشه",
Expand Down Expand Up @@ -295,8 +296,8 @@
"goToPage": "رفتن به صفحه {page} از {total}",
"appsAndFolders": "برنامه‌ها و پوشه‌ها",
"searchResults": "نتایج جستجو",
"result": "نتیجه",
"results": "نتیجه",
"resultCount_one": "{count} نتیجه",
"resultCount_other": "{count} نتیجه",
"apiDownTitle": "اتصال به API داشبورد ممکن نشد",
"apiDownSub": "مطمئن شوید کانتینر API در حال اجراست",
"retry": "تلاش دوباره",
Expand Down Expand Up @@ -400,7 +401,15 @@
"status": {
"needsAttention": "وضعیت: نیاز به رسیدگی",
"healthy": "وضعیت: سالم",
"pending": "{count} در انتظار",
"pending_one": "{count} در انتظار",
"pending_other": "{count} در انتظار",
"pendingValue": "{value} در انتظار",
"containerCreated": "کانتینر ساخته شده",
"containerRestarting": "کانتینر در حال راه‌اندازی دوباره",
"containerRemoving": "کانتینر در حال حذف",
"containerPaused": "کانتینر موقتا متوقف شده",
"containerExited": "کانتینر متوقف شده",
"containerDead": "کانتینر از کار افتاده",
"stale": "(ممکن است قدیمی باشد)",
"moreValues_one": "{count} نشان دیگر",
"moreValues_other": "{count} نشان دیگر",
Expand Down
17 changes: 14 additions & 3 deletions ui/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
},
"type": {
"addNew": "Ajouter",
"addNewNamed": "Ajouter : {name}",
"app": "Application",
"widget": "Widget",
"folder": "Dossier",
Expand Down Expand Up @@ -300,8 +301,9 @@
"goToPage": "Aller à la page {page} sur {total}",
"appsAndFolders": "Applications et dossiers",
"searchResults": "Résultats de recherche",
"result": "résultat",
"results": "résultats",
"resultCount_one": "{count} résultat",
"resultCount_many": "{count} de résultats",
"resultCount_other": "{count} résultats",
"apiDownTitle": "Connexion à l’API du tableau de bord impossible",
"apiDownSub": "Vérifiez que le conteneur de l’API est démarré",
"retry": "Réessayer",
Expand Down Expand Up @@ -406,7 +408,16 @@
"status": {
"needsAttention": "État : nécessite une intervention",
"healthy": "État : normal",
"pending": "{count} en attente",
"pending_one": "{count} en attente",
"pending_many": "{count} en attente",
"pending_other": "{count} en attente",
"pendingValue": "{value} en attente",
"containerCreated": "Conteneur créé",
"containerRestarting": "Conteneur en redémarrage",
"containerRemoving": "Suppression du conteneur",
"containerPaused": "Conteneur en pause",
"containerExited": "Conteneur arrêté",
"containerDead": "Conteneur en échec",
"stale": "(peut-être obsolète)",
"moreValues_one": "{count} badge de plus",
"moreValues_many": "{count} badges de plus",
Expand Down
13 changes: 10 additions & 3 deletions ui/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
},
"type": {
"addNew": "新建",
"addNewNamed": "新建:{name}",
"app": "应用",
"widget": "小组件",
"folder": "文件夹",
Expand Down Expand Up @@ -290,8 +291,7 @@
"goToPage": "转到第 {page} 页,共 {total} 页",
"appsAndFolders": "应用与文件夹",
"searchResults": "搜索结果",
"result": "个结果",
"results": "个结果",
"resultCount_other": "{count} 个结果",
"apiDownTitle": "无法连接到仪表板 API",
"apiDownSub": "请确认 API 容器正在运行",
"retry": "重试",
Expand Down Expand Up @@ -394,7 +394,14 @@
"status": {
"needsAttention": "状态:需要处理",
"healthy": "状态:正常",
"pending": "{count} 项待处理",
"pending_other": "{count} 项待处理",
"pendingValue": "{value} 待处理",
"containerCreated": "容器已创建",
"containerRestarting": "容器正在重启",
"containerRemoving": "容器正在删除",
"containerPaused": "容器已暂停",
"containerExited": "容器已停止",
"containerDead": "容器已失效",
"stale": "(可能已过时)",
"moreValues_other": "另有 {count} 个徽章",
"containerNotFound": "未找到容器",
Expand Down
2 changes: 1 addition & 1 deletion ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,5 @@
</div>
</div>
</dialog>
<script type="module" src="/js/dashboard.js?v=cde5d1ae"></script>
<script type="module" src="/js/dashboard.js?v=3290dfd1"></script>
</body></html>
14 changes: 7 additions & 7 deletions ui/js/admin-app-form.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=0d64dfd3';
import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=b6e6c040';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c7b5111';
import { state } from '/js/admin-state.js?v=af772a1b';
import { DOCK_MAX } from '/js/limits.js?v=31048a24';
import { formatNumber } from '/js/format-number.js?v=349a741d';
import { formatNumber } from '/js/format-number.js?v=52226e9c';
import {
isDockBlocked,
clearsStoredSecret,
Expand All @@ -12,7 +12,7 @@ import {
nextActiveIndex,
sameIconName,
} from '/js/admin-logic.js?v=fc7f0836';
import { t } from '/js/i18n.js?v=5579776a';
import { t } from '/js/i18n.js?v=f754f6fe';
import {
toast,
errorText,
Expand All @@ -24,10 +24,10 @@ import {
reveal,
setTogDisabled,
swapContent,
} from '/js/admin-shared.js?v=2b48ff3f';
import { createListbox } from '/js/listbox.js?v=35d8318a';
import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b';
import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=3b14e88c';
} from '/js/admin-shared.js?v=38ed2b51';
import { createListbox } from '/js/listbox.js?v=ae7b9a38';
import { MAX_LABELS } from '/js/badge-logic.js?v=e3c32915';
import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=44fdebde';
import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957';
import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
import { iconSvg } from '/js/icon-set.js?v=628341d4';
Expand Down
Loading
Loading