Commit c19f76d2 authored by jan.koester's avatar jan.koester
Browse files

test

parent 197b36d7
Loading
Loading
Loading
Loading
+31 −3
Changes for modules/containers/containers.cpp: 31 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -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;
@@ -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
@@ -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}});
+1 −1
Changes for src/frontend/main.cpp: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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 {
+2 −0
Changes for www/app.css: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 {
+96 −7
Changes for www/modules/containers.js: 96 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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'};
@@ -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);
@@ -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) {
@@ -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)));
@@ -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.')),
        ];
      },
@@ -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));
@@ -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
@@ -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')),
@@ -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);