Loading modules/containers/containers.cpp +31 −3 Changes for modules/containers/containers.cpp: 31 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -51,6 +51,11 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * log {name, lines} -> {lines[]} * pull {image} in the background -> {background, job} * removeImage {image} * apps -> {registry, updated, apps: [{name, image, * description, owner, tags[], icon? (base64 * webp)}]}, from containerd's cache * refreshApps in the background -> {background, job} * install {container} pull, create and start, in the background * * A console: a shell in a running container (containerctl shell) on a * pseudo terminal, its bytes base64 in both directions. The browser polls; Loading Loading @@ -235,6 +240,27 @@ json_object *listAll() { return out; } json_object *apps() { sysbus::Message r; if (json_object *e = callContainerd(containerd::method::ListApps, {}, r)) return e; json_object *out = json_object_new_object(); json_object *list = json_object_new_array(); json_object *app = nullptr; for (auto &f : r.fields) { if (f.first == "registry") json_object_object_add(out, "registry", jstr(f.second)); else if (f.first == "updated") json_object_object_add(out, "updated", json_object_new_int64(std::strtoll(f.second.c_str(), nullptr, 10))); else if (f.first == "app") { app = json_object_new_object(); json_object_object_add(app, "name", jstr(f.second)); json_object_object_add(app, "tags", json_object_new_array()); json_object_array_add(list, app); } else if (app && f.first == "tag") json_object_array_add(json_object_object_get(app, "tags"), jstr(f.second)); else if (app) json_object_object_add(app, f.first.c_str(), jstr(f.second)); } json_object_object_add(out, "apps", list); return out; } const char *yesNo(bool b) { return b ? "yes" : "no"; } // the UI's container as containerd's fields; "" or why not Loading Loading @@ -521,18 +547,20 @@ json_object *call(const webctl_request *req) { if (action == "list") return listAll(); if (action == "get") return get(name); if (action == "log") return log(name, webctl::integer(a, "lines", 200)); if (action == "create" || action == "update") { if (action == "apps") return apps(); if (action == "refreshApps") return simple(m::RefreshApps, {}); if (action == "create" || action == "update" || action == "install") { json_object *c = nullptr; json_object_object_get_ex(a, "container", &c); Fields f; std::string why = settingsFields(c, f, action == "create"); std::string why = settingsFields(c, f, action != "update"); if (!why.empty()) return error(why); if (action == "update") { f.insert(f.begin(), {"name", name}); std::string newName = webctl::str(c, "name", name); if (newName != name) f.emplace_back("new_name", newName); } return simple(action == "create" ? m::CreateContainer : m::EditContainer, f); return simple(action == "create" ? m::CreateContainer : action == "install" ? m::InstallApp : m::EditContainer, f); } if (action == "delete") return simple(m::DeleteContainer, {{"name", name}}); if (action == "start") return simple(m::StartContainer, {{"name", name}}); Loading src/frontend/main.cpp +1 −1 Changes for src/frontend/main.cpp: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -151,7 +151,7 @@ void securityHeaders(libhttppp::HttpResponse &res) { res.setHeaderData("x-frame-options")->push_back("DENY"); res.setHeaderData("referrer-policy")->push_back("no-referrer"); res.setHeaderData("content-security-policy") ->push_back("default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); ->push_back("default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); } class Controller : public libhttppp::HttpEvent { Loading www/app.css +2 −0 Changes for www/app.css: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -106,6 +106,8 @@ label.check { color: var(--text); font-size: 1em; } .toolbar h2 { margin: 0 auto 0 0; } .toolbar input[type=search] { width: 220px; } img.app-icon { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; display: block; } /* tabs: one page of a module at a time */ .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line-strong); margin-bottom: 16px; overflow-x: auto; } .tabs button { Loading www/modules/containers.js +96 −7 Changes for www/modules/containers.js: 96 added lines, 7 removed lines. Original line number Diff line number Diff line Loading @@ -82,11 +82,24 @@ 'Settings are kept like systemd-nspawn keeps them: /etc/containerd/<name>.nspawn.': 'Einstellungen liegen wie bei systemd-nspawn in /etc/containerd/<name>.nspawn.', 'default: {0}': 'Vorgabe: {0}', 'Apps': 'Apps', 'Install': 'Installieren', 'Install {0}': '{0} installieren', 'App': 'App', 'Version': 'Version', 'Installed: {0}': 'Installiert: {0}', 'From {0}': 'Von {0}', 'As of {0}': 'Stand: {0}', 'No description': 'Keine Beschreibung', 'Reading the apps …': 'Apps werden gelesen …', 'No apps yet. The registry offers none that this system may read.': 'Noch keine Apps. Die Registry bietet keine an, die dieses System lesen darf.', 'The list could not be read: {0}': 'Die Liste ließ sich nicht lesen: {0}', 'The image is pulled, then the container is created and started.': 'Das Image wird geladen, dann wird der Container angelegt und gestartet.', }}); let root; let data; let timer; let tab = 'containers'; try { tab = localStorage.getItem('webctl.containers.tab') || 'containers'; } catch (e) { /* private mode */ } let store = null; // what "apps" answered let storeJob = null; // the job reading them let storeError = ''; let storeAsked = false; const StoreMaxAge = 3600; // seconds const mib = b => (b / 1048576).toFixed(b < 10485760 ? 1 : 0) + ' MiB'; const stateClass = {running: 'ok', starting: 'warn busy', stopping: 'warn busy', exited: '', stopped: '', failed: 'danger'}; Loading Loading @@ -124,10 +137,46 @@ } data = r; jobs.report('containers', data.jobs); if (tab === 'apps') await loadApps(); draw(); schedule(); } async function refreshApps() { storeError = ''; const r = await run(() => call('containers', 'refreshApps')); if (r && r.job) storeJob = r.job.id; } // the list from containerd's cache, read again from the registry when // that is old; the job's end brings the new one async function loadApps() { const job = storeJob !== null ? (data.jobs || []).find(j => j.id === storeJob) : null; const reading = job && job.state === 'running'; if (store && !(job && !reading)) return; if (job && !reading) { storeError = job.state === 'failed' ? (job.error || t('failed')) : ''; storeJob = null; } try { store = await call('containers', 'apps'); } catch (e) { storeError = e.message; store = store || {apps: []}; return; } if (!storeAsked && !reading && Date.now() / 1000 - (store.updated || 0) > StoreMaxAge) { storeAsked = true; await refreshApps(); } } function setTab(name) { tab = name; try { localStorage.setItem('webctl.containers.tab', name); } catch (e) { /* private mode */ } load(); } // quicker while something is on its way function schedule() { clearTimeout(timer); Loading Loading @@ -275,13 +324,16 @@ })(); } async function containerDialog(name) { // app: installing it, a new container on an image not pulled yet async function containerDialog(name, app) { const images = data.images || []; if (!name && !images.length) { if (!name && !app && !images.length) { toast(t('Pull an image first.'), true); return; } let c = {name: '', image: images.length ? images[0].image : '', network: 'host', restart: 'no', autostart: true, const choices = app ? app.tags.map(tag => app.image + ':' + tag) : images.map(i => i.image); let c = {name: app ? app.name.split('/').pop().replace(/[^A-Za-z0-9_.\-]/g, '-') : '', image: choices.length ? choices[0] : '', network: 'host', restart: 'no', autostart: true, entrypoint: [], args: [], env: [], dns: [], binds: [], tmpfs: [], capabilities: [], dropCapabilities: [], memoryMax: 0, tasksMax: 0}; if (name) { Loading @@ -291,11 +343,12 @@ } const bridges = data.bridges || []; dialog({ title: name ? t('Container {0}', name) : t('New container'), submit: name ? undefined : t('Create'), wide: true, title: name ? t('Container {0}', name) : app ? t('Install {0}', app.name) : t('New container'), submit: name ? undefined : app ? t('Install') : t('Create'), wide: true, fields: () => { const imageSel = h('select', {name: 'image', required: true}, images.map(i => h('option', {value: i.image, selected: i.image === c.image}, i.image)), c.image && !images.some(i => i.image === c.image) ? h('option', {value: c.image, selected: true}, c.image) : null); choices.map(i => h('option', {value: i, selected: i === c.image}, i)), c.image && !choices.includes(c.image) ? h('option', {value: c.image, selected: true}, c.image) : null); const net = h('select', {name: 'network'}, [['host', t('Host network (like nspawn without --private-network)')], ['none', t('Only loopback')], ['bridge', t('Own address in a bridge')]].map(([v, l]) => h('option', {value: v, selected: c.network === v}, l))); Loading Loading @@ -391,6 +444,7 @@ h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'boot', checked: c.boot}), t('Boot the image\'s init (an OS image, like nspawn --boot)'))), h('p', {class: 'hint'}, (name ? t('Changes apply at the next start.') + ' ' : '') + (app ? t('The image is pulled, then the container is created and started.') + ' ' : '') + t('Settings are kept like systemd-nspawn keeps them: /etc/containerd/<name>.nspawn.')), ]; }, Loading @@ -414,6 +468,11 @@ capabilities: words(d.capabilities), dropCapabilities: words(d.dropCapabilities), readOnly: d.readOnly, boot: d.boot, }; if (app) { jobs.started(await call('containers', 'install', {container})); load(); return; } if (name) await call('containers', 'update', {name, container}); else await call('containers', 'create', {container}); toast(name ? t('Container {0} saved.', container.name) : t('Container {0} created.', container.name)); Loading @@ -435,6 +494,8 @@ h('button', {onclick: load}, t('Try again')))); return; } const tabItem = (name, label) => h('button', {class: tab === name ? 'active' : null, role: 'tab', 'aria-selected': tab === name ? 'true' : 'false', onclick: () => tab !== name && setTab(name)}, label); const running = (data.jobs || []).filter(j => j.state === 'running'); const short = id => (id || '').replace('sha256:', '').slice(0, 12); // replaceChildren() would show a null as the text "null": filtered out Loading @@ -443,6 +504,8 @@ h('div', {class: 'toolbar'}, h('h2', {}, t('Running actions'))), h('ul', {class: 'job-list'}, running.map(j => h('li', {}, h('span', {class: 'badge warn busy'}, j.title), ' ', h('span', {class: 'muted'}, j.progress || jobs.duration(j.seconds)))))) : null, h('div', {class: 'tabs', role: 'tablist'}, tabItem('containers', t('Containers')), tabItem('apps', t('Apps'))), ...(tab === 'apps' ? [appsPanel()] : [ h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Containers')), Loading Loading @@ -491,13 +554,39 @@ }}, t('Update')), (i.usedBy || []).length ? null : h('button', {class: 'small danger', onclick: () => removeImage(i)}, t('Remove')), ]}, ], data.images || [], t('No images yet. Pull one, e.g. nginx or debian:stable.')))].filter(Boolean)); ], data.images || [], t('No images yet. Pull one, e.g. nginx or debian:stable.')))])].filter(Boolean)); } function appsPanel() { const list = (store && store.apps) || []; const reading = storeJob !== null; const usedBy = app => (data.containers || []).filter(c => (c.image || '').startsWith(app.image + ':')).map(c => c.name); return h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Apps')), h('button', {disabled: reading, onclick: async () => { await refreshApps(); load(); }}, t('Refresh'))), store && store.registry ? h('p', {class: 'hint'}, t('From {0}', store.registry) + (store.updated ? ' · ' + t('As of {0}', new Date(store.updated * 1000).toLocaleString()) : '')) : null, storeError ? h('p', {class: 'dialog-error'}, t('The list could not be read: {0}', storeError)) : null, table([ {title: '', render: a => a.icon ? h('img', {class: 'app-icon', alt: '', src: 'data:image/webp;base64,' + a.icon}) : ''}, {title: t('App'), render: a => [h('strong', {}, a.name), h('div', {class: 'hint'}, a.description || t('No description'))]}, {title: t('Version'), render: a => h('span', {class: 'mono'}, a.tags[0])}, {title: '', class: 'actions', render: a => { const on = usedBy(a); return [on.length ? h('span', {class: 'badge ok'}, t('Installed: {0}', on.join(', '))) : null, ' ', h('button', {class: 'small primary', onclick: () => containerDialog(null, a)}, t('Install'))]; }}, ], list, reading ? t('Reading the apps …') : t('No apps yet. The registry offers none that this system may read.'))); } webctl.register('containers', { render(container) { root = container; data = null; store = null; storeAsked = false; storeError = ''; root.append(h('p', {class: 'empty'}, t('Loading …'))); load(); return () => clearTimeout(timer); Loading Loading
modules/containers/containers.cpp +31 −3 Changes for modules/containers/containers.cpp: 31 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -51,6 +51,11 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * log {name, lines} -> {lines[]} * pull {image} in the background -> {background, job} * removeImage {image} * apps -> {registry, updated, apps: [{name, image, * description, owner, tags[], icon? (base64 * webp)}]}, from containerd's cache * refreshApps in the background -> {background, job} * install {container} pull, create and start, in the background * * A console: a shell in a running container (containerctl shell) on a * pseudo terminal, its bytes base64 in both directions. The browser polls; Loading Loading @@ -235,6 +240,27 @@ json_object *listAll() { return out; } json_object *apps() { sysbus::Message r; if (json_object *e = callContainerd(containerd::method::ListApps, {}, r)) return e; json_object *out = json_object_new_object(); json_object *list = json_object_new_array(); json_object *app = nullptr; for (auto &f : r.fields) { if (f.first == "registry") json_object_object_add(out, "registry", jstr(f.second)); else if (f.first == "updated") json_object_object_add(out, "updated", json_object_new_int64(std::strtoll(f.second.c_str(), nullptr, 10))); else if (f.first == "app") { app = json_object_new_object(); json_object_object_add(app, "name", jstr(f.second)); json_object_object_add(app, "tags", json_object_new_array()); json_object_array_add(list, app); } else if (app && f.first == "tag") json_object_array_add(json_object_object_get(app, "tags"), jstr(f.second)); else if (app) json_object_object_add(app, f.first.c_str(), jstr(f.second)); } json_object_object_add(out, "apps", list); return out; } const char *yesNo(bool b) { return b ? "yes" : "no"; } // the UI's container as containerd's fields; "" or why not Loading Loading @@ -521,18 +547,20 @@ json_object *call(const webctl_request *req) { if (action == "list") return listAll(); if (action == "get") return get(name); if (action == "log") return log(name, webctl::integer(a, "lines", 200)); if (action == "create" || action == "update") { if (action == "apps") return apps(); if (action == "refreshApps") return simple(m::RefreshApps, {}); if (action == "create" || action == "update" || action == "install") { json_object *c = nullptr; json_object_object_get_ex(a, "container", &c); Fields f; std::string why = settingsFields(c, f, action == "create"); std::string why = settingsFields(c, f, action != "update"); if (!why.empty()) return error(why); if (action == "update") { f.insert(f.begin(), {"name", name}); std::string newName = webctl::str(c, "name", name); if (newName != name) f.emplace_back("new_name", newName); } return simple(action == "create" ? m::CreateContainer : m::EditContainer, f); return simple(action == "create" ? m::CreateContainer : action == "install" ? m::InstallApp : m::EditContainer, f); } if (action == "delete") return simple(m::DeleteContainer, {{"name", name}}); if (action == "start") return simple(m::StartContainer, {{"name", name}}); Loading
src/frontend/main.cpp +1 −1 Changes for src/frontend/main.cpp: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -151,7 +151,7 @@ void securityHeaders(libhttppp::HttpResponse &res) { res.setHeaderData("x-frame-options")->push_back("DENY"); res.setHeaderData("referrer-policy")->push_back("no-referrer"); res.setHeaderData("content-security-policy") ->push_back("default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); ->push_back("default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); } class Controller : public libhttppp::HttpEvent { Loading
www/app.css +2 −0 Changes for www/app.css: 2 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -106,6 +106,8 @@ label.check { color: var(--text); font-size: 1em; } .toolbar h2 { margin: 0 auto 0 0; } .toolbar input[type=search] { width: 220px; } img.app-icon { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; display: block; } /* tabs: one page of a module at a time */ .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line-strong); margin-bottom: 16px; overflow-x: auto; } .tabs button { Loading
www/modules/containers.js +96 −7 Changes for www/modules/containers.js: 96 added lines, 7 removed lines. Original line number Diff line number Diff line Loading @@ -82,11 +82,24 @@ 'Settings are kept like systemd-nspawn keeps them: /etc/containerd/<name>.nspawn.': 'Einstellungen liegen wie bei systemd-nspawn in /etc/containerd/<name>.nspawn.', 'default: {0}': 'Vorgabe: {0}', 'Apps': 'Apps', 'Install': 'Installieren', 'Install {0}': '{0} installieren', 'App': 'App', 'Version': 'Version', 'Installed: {0}': 'Installiert: {0}', 'From {0}': 'Von {0}', 'As of {0}': 'Stand: {0}', 'No description': 'Keine Beschreibung', 'Reading the apps …': 'Apps werden gelesen …', 'No apps yet. The registry offers none that this system may read.': 'Noch keine Apps. Die Registry bietet keine an, die dieses System lesen darf.', 'The list could not be read: {0}': 'Die Liste ließ sich nicht lesen: {0}', 'The image is pulled, then the container is created and started.': 'Das Image wird geladen, dann wird der Container angelegt und gestartet.', }}); let root; let data; let timer; let tab = 'containers'; try { tab = localStorage.getItem('webctl.containers.tab') || 'containers'; } catch (e) { /* private mode */ } let store = null; // what "apps" answered let storeJob = null; // the job reading them let storeError = ''; let storeAsked = false; const StoreMaxAge = 3600; // seconds const mib = b => (b / 1048576).toFixed(b < 10485760 ? 1 : 0) + ' MiB'; const stateClass = {running: 'ok', starting: 'warn busy', stopping: 'warn busy', exited: '', stopped: '', failed: 'danger'}; Loading Loading @@ -124,10 +137,46 @@ } data = r; jobs.report('containers', data.jobs); if (tab === 'apps') await loadApps(); draw(); schedule(); } async function refreshApps() { storeError = ''; const r = await run(() => call('containers', 'refreshApps')); if (r && r.job) storeJob = r.job.id; } // the list from containerd's cache, read again from the registry when // that is old; the job's end brings the new one async function loadApps() { const job = storeJob !== null ? (data.jobs || []).find(j => j.id === storeJob) : null; const reading = job && job.state === 'running'; if (store && !(job && !reading)) return; if (job && !reading) { storeError = job.state === 'failed' ? (job.error || t('failed')) : ''; storeJob = null; } try { store = await call('containers', 'apps'); } catch (e) { storeError = e.message; store = store || {apps: []}; return; } if (!storeAsked && !reading && Date.now() / 1000 - (store.updated || 0) > StoreMaxAge) { storeAsked = true; await refreshApps(); } } function setTab(name) { tab = name; try { localStorage.setItem('webctl.containers.tab', name); } catch (e) { /* private mode */ } load(); } // quicker while something is on its way function schedule() { clearTimeout(timer); Loading Loading @@ -275,13 +324,16 @@ })(); } async function containerDialog(name) { // app: installing it, a new container on an image not pulled yet async function containerDialog(name, app) { const images = data.images || []; if (!name && !images.length) { if (!name && !app && !images.length) { toast(t('Pull an image first.'), true); return; } let c = {name: '', image: images.length ? images[0].image : '', network: 'host', restart: 'no', autostart: true, const choices = app ? app.tags.map(tag => app.image + ':' + tag) : images.map(i => i.image); let c = {name: app ? app.name.split('/').pop().replace(/[^A-Za-z0-9_.\-]/g, '-') : '', image: choices.length ? choices[0] : '', network: 'host', restart: 'no', autostart: true, entrypoint: [], args: [], env: [], dns: [], binds: [], tmpfs: [], capabilities: [], dropCapabilities: [], memoryMax: 0, tasksMax: 0}; if (name) { Loading @@ -291,11 +343,12 @@ } const bridges = data.bridges || []; dialog({ title: name ? t('Container {0}', name) : t('New container'), submit: name ? undefined : t('Create'), wide: true, title: name ? t('Container {0}', name) : app ? t('Install {0}', app.name) : t('New container'), submit: name ? undefined : app ? t('Install') : t('Create'), wide: true, fields: () => { const imageSel = h('select', {name: 'image', required: true}, images.map(i => h('option', {value: i.image, selected: i.image === c.image}, i.image)), c.image && !images.some(i => i.image === c.image) ? h('option', {value: c.image, selected: true}, c.image) : null); choices.map(i => h('option', {value: i, selected: i === c.image}, i)), c.image && !choices.includes(c.image) ? h('option', {value: c.image, selected: true}, c.image) : null); const net = h('select', {name: 'network'}, [['host', t('Host network (like nspawn without --private-network)')], ['none', t('Only loopback')], ['bridge', t('Own address in a bridge')]].map(([v, l]) => h('option', {value: v, selected: c.network === v}, l))); Loading Loading @@ -391,6 +444,7 @@ h('label', {class: 'check'}, h('input', {type: 'checkbox', name: 'boot', checked: c.boot}), t('Boot the image\'s init (an OS image, like nspawn --boot)'))), h('p', {class: 'hint'}, (name ? t('Changes apply at the next start.') + ' ' : '') + (app ? t('The image is pulled, then the container is created and started.') + ' ' : '') + t('Settings are kept like systemd-nspawn keeps them: /etc/containerd/<name>.nspawn.')), ]; }, Loading @@ -414,6 +468,11 @@ capabilities: words(d.capabilities), dropCapabilities: words(d.dropCapabilities), readOnly: d.readOnly, boot: d.boot, }; if (app) { jobs.started(await call('containers', 'install', {container})); load(); return; } if (name) await call('containers', 'update', {name, container}); else await call('containers', 'create', {container}); toast(name ? t('Container {0} saved.', container.name) : t('Container {0} created.', container.name)); Loading @@ -435,6 +494,8 @@ h('button', {onclick: load}, t('Try again')))); return; } const tabItem = (name, label) => h('button', {class: tab === name ? 'active' : null, role: 'tab', 'aria-selected': tab === name ? 'true' : 'false', onclick: () => tab !== name && setTab(name)}, label); const running = (data.jobs || []).filter(j => j.state === 'running'); const short = id => (id || '').replace('sha256:', '').slice(0, 12); // replaceChildren() would show a null as the text "null": filtered out Loading @@ -443,6 +504,8 @@ h('div', {class: 'toolbar'}, h('h2', {}, t('Running actions'))), h('ul', {class: 'job-list'}, running.map(j => h('li', {}, h('span', {class: 'badge warn busy'}, j.title), ' ', h('span', {class: 'muted'}, j.progress || jobs.duration(j.seconds)))))) : null, h('div', {class: 'tabs', role: 'tablist'}, tabItem('containers', t('Containers')), tabItem('apps', t('Apps'))), ...(tab === 'apps' ? [appsPanel()] : [ h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Containers')), Loading Loading @@ -491,13 +554,39 @@ }}, t('Update')), (i.usedBy || []).length ? null : h('button', {class: 'small danger', onclick: () => removeImage(i)}, t('Remove')), ]}, ], data.images || [], t('No images yet. Pull one, e.g. nginx or debian:stable.')))].filter(Boolean)); ], data.images || [], t('No images yet. Pull one, e.g. nginx or debian:stable.')))])].filter(Boolean)); } function appsPanel() { const list = (store && store.apps) || []; const reading = storeJob !== null; const usedBy = app => (data.containers || []).filter(c => (c.image || '').startsWith(app.image + ':')).map(c => c.name); return h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Apps')), h('button', {disabled: reading, onclick: async () => { await refreshApps(); load(); }}, t('Refresh'))), store && store.registry ? h('p', {class: 'hint'}, t('From {0}', store.registry) + (store.updated ? ' · ' + t('As of {0}', new Date(store.updated * 1000).toLocaleString()) : '')) : null, storeError ? h('p', {class: 'dialog-error'}, t('The list could not be read: {0}', storeError)) : null, table([ {title: '', render: a => a.icon ? h('img', {class: 'app-icon', alt: '', src: 'data:image/webp;base64,' + a.icon}) : ''}, {title: t('App'), render: a => [h('strong', {}, a.name), h('div', {class: 'hint'}, a.description || t('No description'))]}, {title: t('Version'), render: a => h('span', {class: 'mono'}, a.tags[0])}, {title: '', class: 'actions', render: a => { const on = usedBy(a); return [on.length ? h('span', {class: 'badge ok'}, t('Installed: {0}', on.join(', '))) : null, ' ', h('button', {class: 'small primary', onclick: () => containerDialog(null, a)}, t('Install'))]; }}, ], list, reading ? t('Reading the apps …') : t('No apps yet. The registry offers none that this system may read.'))); } webctl.register('containers', { render(container) { root = container; data = null; store = null; storeAsked = false; storeError = ''; root.append(h('p', {class: 'empty'}, t('Loading …'))); load(); return () => clearTimeout(timer); Loading