Loading modules/containers/containers.cpp +222 −0 Changes for modules/containers/containers.cpp: 222 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -49,6 +49,14 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * log {name, lines} -> {lines[]} * pull {image} in the background -> {background, job} * removeImage {image} * * A console: a shell in a running container (containerctl shell) on a * pseudo terminal, its bytes base64 in both directions. The browser polls; * a console belongs to the user who opened it and is closed after two * minutes without a poll. * consoleOpen {name, cols, rows} -> {id} * consoleIO {id, input?, cols?, rows?} -> {output, alive} * consoleClose {id} */ #include <cstdlib> Loading @@ -57,8 +65,19 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include <string> #include <vector> #include <map> #include <dirent.h> #include <fcntl.h> #include <poll.h> #include <pty.h> #include <signal.h> #include <sys/ioctl.h> #include <sys/random.h> #include <sys/stat.h> #include <sys/wait.h> #include <time.h> #include <unistd.h> #include <containerd/api.h> #include <sysbus/client.h> Loading Loading @@ -285,6 +304,206 @@ json_object *log(const std::string &name, long lines) { return out; } // ---- consoles ---- struct Console { int fd = -1; pid_t pid = -1; std::string user; time_t seen = 0; bool exited = false; }; std::map<std::string, Console> &consoles() { static std::map<std::string, Console> c; return c; } const size_t MaxConsoles = 16; const time_t ConsoleIdle = 120; std::string base64(const std::string &in) { static const char t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string out; size_t i = 0; for (; i + 2 < in.size(); i += 3) { unsigned v = (unsigned char)in[i] << 16 | (unsigned char)in[i + 1] << 8 | (unsigned char)in[i + 2]; out += t[v >> 18]; out += t[(v >> 12) & 63]; out += t[(v >> 6) & 63]; out += t[v & 63]; } if (i + 1 == in.size()) { unsigned v = (unsigned char)in[i] << 16; out += t[v >> 18]; out += t[(v >> 12) & 63]; out += "=="; } else if (i + 2 == in.size()) { unsigned v = (unsigned char)in[i] << 16 | (unsigned char)in[i + 1] << 8; out += t[v >> 18]; out += t[(v >> 12) & 63]; out += t[(v >> 6) & 63]; out += '='; } return out; } bool unbase64(const std::string &in, std::string &out) { auto val = [](char c) -> int { if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') return c - 'a' + 26; if (c >= '0' && c <= '9') return c - '0' + 52; if (c == '+') return 62; if (c == '/') return 63; return -1; }; unsigned acc = 0; int bits = 0; for (char c : in) { if (c == '=') break; int v = val(c); if (v < 0) return false; acc = acc << 6 | (unsigned)v; bits += 6; if (bits >= 8) { bits -= 8; out += (char)((acc >> bits) & 0xff); } } return true; } void closeConsole(Console &c) { if (c.pid > 0 && !c.exited) { kill(-c.pid, SIGHUP); // its session: the shell and what it started kill(c.pid, SIGHUP); } if (c.fd >= 0) close(c.fd); if (c.pid > 0) waitpid(c.pid, nullptr, c.exited ? WNOHANG : 0); c.fd = -1; c.pid = -1; } // consoles nobody polls any more void reapConsoles() { time_t now = time(nullptr); for (auto it = consoles().begin(); it != consoles().end();) { if (now - it->second.seen > ConsoleIdle) { closeConsole(it->second); it = consoles().erase(it); } else { ++it; } } } void setSize(int fd, long cols, long rows) { if (cols < 10 || rows < 3 || cols > 1000 || rows > 500) return; struct winsize ws = {}; ws.ws_col = (unsigned short)cols; ws.ws_row = (unsigned short)rows; ioctl(fd, TIOCSWINSZ, &ws); } json_object *consoleOpen(const webctl_request *req, const std::string &name) { reapConsoles(); if (name.empty()) return error(tr("no container given", "kein Container angegeben")); if (consoles().size() >= MaxConsoles) return error(tr("too many open consoles", "zu viele offene Konsolen"), 503); sysbus::Message r; if (json_object *e = callContainerd(containerd::method::GetContainer, {{"name", name}}, r)) return e; if (r.get("state") != "running") return error(tr("%1 isn't running", "%1 läuft nicht", {name}), 409); unsigned char raw[16]; if (getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw)) return error("getrandom", 500); static const char hex[] = "0123456789abcdef"; std::string id; for (unsigned char b : raw) { id += hex[b >> 4]; id += hex[b & 15]; } struct winsize ws = {}; ws.ws_col = 80; ws.ws_row = 24; int fd = -1; pid_t pid = forkpty(&fd, nullptr, nullptr, &ws); if (pid < 0) return error(std::string("forkpty: ") + strerror(errno), 500); if (pid == 0) { // the backend's descriptors stay out of the container for (int i = 3; i < 1024; ++i) close(i); setenv("TERM", "xterm-256color", 1); setenv("PATH", "/usr/sbin:/usr/bin:/sbin:/bin", 1); execlp("containerctl", "containerctl", "shell", name.c_str(), (char *)nullptr); _exit(127); } fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK); fcntl(fd, F_SETFD, FD_CLOEXEC); setSize(fd, webctl::integer(req->args, "cols", 80), webctl::integer(req->args, "rows", 24)); Console &c = consoles()[id]; c.fd = fd; c.pid = pid; c.user = req->user ? req->user : ""; c.seen = time(nullptr); json_object *out = json_object_new_object(); json_object_object_add(out, "id", jstr(id)); return out; } Console *consoleOf(const webctl_request *req, const std::string &id, json_object **err) { auto it = consoles().find(id); if (it == consoles().end() || it->second.user != (req->user ? req->user : "")) { *err = error(tr("no such console", "keine solche Konsole"), 404); return nullptr; } it->second.seen = time(nullptr); return &it->second; } json_object *consoleIO(const webctl_request *req, const std::string &id) { json_object *err = nullptr; Console *c = consoleOf(req, id, &err); if (!c) return err; if (webctl::has(req->args, "cols")) setSize(c->fd, webctl::integer(req->args, "cols", 80), webctl::integer(req->args, "rows", 24)); std::string input; if (!unbase64(webctl::str(req->args, "input"), input)) return error(tr("invalid input", "ungültige Eingabe")); for (size_t off = 0; off < input.size();) { ssize_t n = write(c->fd, input.data() + off, input.size() - off); if (n > 0) { off += (size_t)n; continue; } if (n < 0 && errno == EINTR) continue; if (n < 0 && errno == EAGAIN) { pollfd p = {c->fd, POLLOUT, 0}; if (poll(&p, 1, 200) > 0) continue; } break; } // what is there, waiting a little for a reply to what was typed std::string out; char buf[16384]; if (!input.empty()) { pollfd p = {c->fd, POLLIN, 0}; poll(&p, 1, 30); } for (;;) { ssize_t n = read(c->fd, buf, sizeof(buf)); if (n > 0) { out.append(buf, (size_t)n); if (out.size() >= 256 * 1024) break; continue; } if (n < 0 && errno == EINTR) continue; break; // EAGAIN, or EIO: the shell is gone } if (!c->exited && waitpid(c->pid, nullptr, WNOHANG) == c->pid) c->exited = true; bool alive = !c->exited || !out.empty(); json_object *o = json_object_new_object(); json_object_object_add(o, "output", jstr(base64(out))); json_object_object_add(o, "alive", json_object_new_boolean(alive)); if (!alive) { closeConsole(*c); consoles().erase(id); } return o; } json_object *consoleClose(const webctl_request *req, const std::string &id) { json_object *err = nullptr; Console *c = consoleOf(req, id, &err); if (!c) return err; closeConsole(*c); consoles().erase(id); return webctl::ok(); } json_object *call(const webctl_request *req) { webctl::LanguageScope lang(req->lang); std::string action = req->action; Loading Loading @@ -315,6 +534,9 @@ json_object *call(const webctl_request *req) { if (action == "kill") return simple(m::KillContainer, {{"name", name}, {"signal", webctl::str(a, "signal", "KILL")}}); if (action == "pull") return simple(m::PullImage, {{"image", webctl::str(a, "image")}}); if (action == "removeImage") return simple(m::RemoveImage, {{"image", webctl::str(a, "image")}}); if (action == "consoleOpen") return consoleOpen(req, name); if (action == "consoleIO") return consoleIO(req, webctl::str(a, "id")); if (action == "consoleClose") return consoleClose(req, webctl::str(a, "id")); return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404); } Loading www/app.css +16 −0 Changes for www/app.css: 16 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -210,3 +210,19 @@ pre.log { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font /* folded parts of a dialog (containers: advanced settings) */ dialog details { margin: .4em 0 .8em; } dialog details > summary { cursor: pointer; color: var(--muted); margin-bottom: .5em; } /* console (terminal.js) */ .terminal { --term-bg: #15171a; --term-fg: #d6d8db; background: var(--term-bg); color: var(--term-fg); font: 14px/1.25 ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace; padding: 6px; border-radius: 6px; overflow: hidden; position: relative; outline: none; cursor: text; } .terminal:focus { box-shadow: 0 0 0 2px var(--accent, #3b82f6); } .terminal-screen > div { white-space: pre; height: 1.25em; } .terminal-measure { position: absolute; visibility: hidden; white-space: pre; } dialog.console { width: min(1100px, 96vw); max-width: 96vw; } dialog.console .terminal { height: min(70vh, 640px); } dialog.console .console-status { margin: .5em 0 0; color: var(--muted, #6b7280); font-size: .875rem; } www/index.html +1 −0 Changes for www/index.html: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ <title>FenrirOS webctl</title> <link rel="icon" type="image/png" href="/favicon.png"> <link rel="stylesheet" href="/app.css"> <script src="/terminal.js" defer></script> <script src="/app.js" defer></script> </head> <body> Loading www/modules/containers.js +81 −0 Changes for www/modules/containers.js: 81 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -8,6 +8,9 @@ 'Containers': 'Container', 'Images': 'Images', 'New container': 'Neuer Container', 'Pull image': 'Image laden', 'Name': 'Name', 'Image': 'Image', 'State': 'Status', 'Network': 'Netzwerk', 'Memory': 'Speicher', 'Start': 'Starten', 'Stop': 'Stoppen', 'Restart': 'Neu starten', 'Log': 'Protokoll', 'Edit': 'Bearbeiten', 'Console': 'Konsole', 'Console of {0}': 'Konsole von {0}', 'Close': 'Schließen', 'A shell in {0} as root. Ctrl+Shift+V pastes.': 'Eine Shell in {0} als root. Strg+Umschalt+V fügt ein.', 'The shell has ended.': 'Die Shell ist beendet.', 'The connection was lost: {0}': 'Die Verbindung ist abgebrochen: {0}', 'running': 'läuft', 'stopped': 'gestoppt', 'starting': 'startet', 'stopping': 'stoppt', 'exited': 'beendet', 'failed': 'fehlgeschlagen', 'exit code {0}': 'Exit-Code {0}', 'restarted {0}×': '{0}× neu gestartet', 'host': 'Host', 'none': 'keins', 'bridge': 'Bridge', Loading Loading @@ -170,6 +173,83 @@ }); } // A shell in a running container in the browser: terminal.js, the bytes // base64 over the module's consoleIO, polled (libhttppp has no WebSocket) function consoleDialog(name) { const toB64 = str => { let bin = ''; for (const b of new TextEncoder().encode(str)) bin += String.fromCharCode(b); return btoa(bin); }; const fromB64 = b64 => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); const screen = h('div', {}); const status = h('p', {class: 'console-status'}, t('A shell in {0} as root. Ctrl+Shift+V pastes.', name)); const dlg = h('dialog', {class: 'console'}, h('h2', {}, t('Console of {0}', name)), screen, status, h('div', {class: 'dialog-buttons'}, h('button', {type: 'button', onclick: () => finish()}, t('Close')))); document.body.append(dlg); dlg.showModal(); let id = null, pending = '', size = null, closed = false, timer = null, idle = 0; const term = new WebctlTerminal(screen, { onData: d => { pending += d; poll(true); }, onResize: (cols, rows) => { size = {cols, rows}; }, }); term.focus(); const finish = async () => { if (closed) return; closed = true; clearTimeout(timer); term.dispose(); dlg.close(); dlg.remove(); if (id) try { await call('containers', 'consoleClose', {id}); } catch (e) { /* already gone */ } }; dlg.addEventListener('cancel', ev => { ev.preventDefault(); }); // Escape belongs to the shell let busy = false, again = false; async function poll(now) { if (closed || !id) return; // before consoleOpen: input waits in pending if (busy) { again = true; return; } clearTimeout(timer); busy = true; try { const args = {id, input: toB64(pending)}; pending = ''; if (size) { Object.assign(args, size); size = null; } const r = await call('containers', 'consoleIO', args); if (r.output) { term.write(fromB64(r.output)); idle = 0; } else idle++; if (!r.alive) { status.textContent = t('The shell has ended.'); id = null; return; } } catch (e) { status.textContent = t('The connection was lost: {0}', e.message); id = null; return; } finally { busy = false; } // quick while something happens, slower when idle const delay = again || now ? 0 : idle < 10 ? 60 : idle < 50 ? 200 : 600; again = false; timer = setTimeout(() => poll(false), delay); } (async () => { try { const r = await call('containers', 'consoleOpen', {name, cols: term.cols, rows: term.rows}); id = r.id; poll(true); } catch (e) { status.textContent = e.message; } })(); } async function containerDialog(name) { const images = data.images || []; if (!name && !images.length) { Loading Loading @@ -337,6 +417,7 @@ up ? h('button', {class: 'small', onclick: () => act('stop', c.name, t('{0} is stopping …', c.name))}, t('Stop')) : h('button', {class: 'small primary', onclick: () => act('start', c.name, t('{0} is running.', c.name))}, t('Start')), up ? h('button', {class: 'small', onclick: () => act('restart', c.name)}, t('Restart')) : null, up ? h('button', {class: 'small', onclick: () => consoleDialog(c.name)}, t('Console')) : null, h('button', {class: 'small', onclick: () => logDialog(c.name)}, t('Log')), h('button', {class: 'small', onclick: () => containerDialog(c.name)}, t('Edit')), up ? null : h('button', {class: 'small danger', onclick: () => removeContainer(c)}, t('Delete')), Loading www/terminal.js 0 → 100644 +399 −0 File added.Preview size limit exceeded, changes collapsed. Show changes Loading
modules/containers/containers.cpp +222 −0 Changes for modules/containers/containers.cpp: 222 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -49,6 +49,14 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * log {name, lines} -> {lines[]} * pull {image} in the background -> {background, job} * removeImage {image} * * A console: a shell in a running container (containerctl shell) on a * pseudo terminal, its bytes base64 in both directions. The browser polls; * a console belongs to the user who opened it and is closed after two * minutes without a poll. * consoleOpen {name, cols, rows} -> {id} * consoleIO {id, input?, cols?, rows?} -> {output, alive} * consoleClose {id} */ #include <cstdlib> Loading @@ -57,8 +65,19 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include <string> #include <vector> #include <map> #include <dirent.h> #include <fcntl.h> #include <poll.h> #include <pty.h> #include <signal.h> #include <sys/ioctl.h> #include <sys/random.h> #include <sys/stat.h> #include <sys/wait.h> #include <time.h> #include <unistd.h> #include <containerd/api.h> #include <sysbus/client.h> Loading Loading @@ -285,6 +304,206 @@ json_object *log(const std::string &name, long lines) { return out; } // ---- consoles ---- struct Console { int fd = -1; pid_t pid = -1; std::string user; time_t seen = 0; bool exited = false; }; std::map<std::string, Console> &consoles() { static std::map<std::string, Console> c; return c; } const size_t MaxConsoles = 16; const time_t ConsoleIdle = 120; std::string base64(const std::string &in) { static const char t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string out; size_t i = 0; for (; i + 2 < in.size(); i += 3) { unsigned v = (unsigned char)in[i] << 16 | (unsigned char)in[i + 1] << 8 | (unsigned char)in[i + 2]; out += t[v >> 18]; out += t[(v >> 12) & 63]; out += t[(v >> 6) & 63]; out += t[v & 63]; } if (i + 1 == in.size()) { unsigned v = (unsigned char)in[i] << 16; out += t[v >> 18]; out += t[(v >> 12) & 63]; out += "=="; } else if (i + 2 == in.size()) { unsigned v = (unsigned char)in[i] << 16 | (unsigned char)in[i + 1] << 8; out += t[v >> 18]; out += t[(v >> 12) & 63]; out += t[(v >> 6) & 63]; out += '='; } return out; } bool unbase64(const std::string &in, std::string &out) { auto val = [](char c) -> int { if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') return c - 'a' + 26; if (c >= '0' && c <= '9') return c - '0' + 52; if (c == '+') return 62; if (c == '/') return 63; return -1; }; unsigned acc = 0; int bits = 0; for (char c : in) { if (c == '=') break; int v = val(c); if (v < 0) return false; acc = acc << 6 | (unsigned)v; bits += 6; if (bits >= 8) { bits -= 8; out += (char)((acc >> bits) & 0xff); } } return true; } void closeConsole(Console &c) { if (c.pid > 0 && !c.exited) { kill(-c.pid, SIGHUP); // its session: the shell and what it started kill(c.pid, SIGHUP); } if (c.fd >= 0) close(c.fd); if (c.pid > 0) waitpid(c.pid, nullptr, c.exited ? WNOHANG : 0); c.fd = -1; c.pid = -1; } // consoles nobody polls any more void reapConsoles() { time_t now = time(nullptr); for (auto it = consoles().begin(); it != consoles().end();) { if (now - it->second.seen > ConsoleIdle) { closeConsole(it->second); it = consoles().erase(it); } else { ++it; } } } void setSize(int fd, long cols, long rows) { if (cols < 10 || rows < 3 || cols > 1000 || rows > 500) return; struct winsize ws = {}; ws.ws_col = (unsigned short)cols; ws.ws_row = (unsigned short)rows; ioctl(fd, TIOCSWINSZ, &ws); } json_object *consoleOpen(const webctl_request *req, const std::string &name) { reapConsoles(); if (name.empty()) return error(tr("no container given", "kein Container angegeben")); if (consoles().size() >= MaxConsoles) return error(tr("too many open consoles", "zu viele offene Konsolen"), 503); sysbus::Message r; if (json_object *e = callContainerd(containerd::method::GetContainer, {{"name", name}}, r)) return e; if (r.get("state") != "running") return error(tr("%1 isn't running", "%1 läuft nicht", {name}), 409); unsigned char raw[16]; if (getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw)) return error("getrandom", 500); static const char hex[] = "0123456789abcdef"; std::string id; for (unsigned char b : raw) { id += hex[b >> 4]; id += hex[b & 15]; } struct winsize ws = {}; ws.ws_col = 80; ws.ws_row = 24; int fd = -1; pid_t pid = forkpty(&fd, nullptr, nullptr, &ws); if (pid < 0) return error(std::string("forkpty: ") + strerror(errno), 500); if (pid == 0) { // the backend's descriptors stay out of the container for (int i = 3; i < 1024; ++i) close(i); setenv("TERM", "xterm-256color", 1); setenv("PATH", "/usr/sbin:/usr/bin:/sbin:/bin", 1); execlp("containerctl", "containerctl", "shell", name.c_str(), (char *)nullptr); _exit(127); } fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK); fcntl(fd, F_SETFD, FD_CLOEXEC); setSize(fd, webctl::integer(req->args, "cols", 80), webctl::integer(req->args, "rows", 24)); Console &c = consoles()[id]; c.fd = fd; c.pid = pid; c.user = req->user ? req->user : ""; c.seen = time(nullptr); json_object *out = json_object_new_object(); json_object_object_add(out, "id", jstr(id)); return out; } Console *consoleOf(const webctl_request *req, const std::string &id, json_object **err) { auto it = consoles().find(id); if (it == consoles().end() || it->second.user != (req->user ? req->user : "")) { *err = error(tr("no such console", "keine solche Konsole"), 404); return nullptr; } it->second.seen = time(nullptr); return &it->second; } json_object *consoleIO(const webctl_request *req, const std::string &id) { json_object *err = nullptr; Console *c = consoleOf(req, id, &err); if (!c) return err; if (webctl::has(req->args, "cols")) setSize(c->fd, webctl::integer(req->args, "cols", 80), webctl::integer(req->args, "rows", 24)); std::string input; if (!unbase64(webctl::str(req->args, "input"), input)) return error(tr("invalid input", "ungültige Eingabe")); for (size_t off = 0; off < input.size();) { ssize_t n = write(c->fd, input.data() + off, input.size() - off); if (n > 0) { off += (size_t)n; continue; } if (n < 0 && errno == EINTR) continue; if (n < 0 && errno == EAGAIN) { pollfd p = {c->fd, POLLOUT, 0}; if (poll(&p, 1, 200) > 0) continue; } break; } // what is there, waiting a little for a reply to what was typed std::string out; char buf[16384]; if (!input.empty()) { pollfd p = {c->fd, POLLIN, 0}; poll(&p, 1, 30); } for (;;) { ssize_t n = read(c->fd, buf, sizeof(buf)); if (n > 0) { out.append(buf, (size_t)n); if (out.size() >= 256 * 1024) break; continue; } if (n < 0 && errno == EINTR) continue; break; // EAGAIN, or EIO: the shell is gone } if (!c->exited && waitpid(c->pid, nullptr, WNOHANG) == c->pid) c->exited = true; bool alive = !c->exited || !out.empty(); json_object *o = json_object_new_object(); json_object_object_add(o, "output", jstr(base64(out))); json_object_object_add(o, "alive", json_object_new_boolean(alive)); if (!alive) { closeConsole(*c); consoles().erase(id); } return o; } json_object *consoleClose(const webctl_request *req, const std::string &id) { json_object *err = nullptr; Console *c = consoleOf(req, id, &err); if (!c) return err; closeConsole(*c); consoles().erase(id); return webctl::ok(); } json_object *call(const webctl_request *req) { webctl::LanguageScope lang(req->lang); std::string action = req->action; Loading Loading @@ -315,6 +534,9 @@ json_object *call(const webctl_request *req) { if (action == "kill") return simple(m::KillContainer, {{"name", name}, {"signal", webctl::str(a, "signal", "KILL")}}); if (action == "pull") return simple(m::PullImage, {{"image", webctl::str(a, "image")}}); if (action == "removeImage") return simple(m::RemoveImage, {{"image", webctl::str(a, "image")}}); if (action == "consoleOpen") return consoleOpen(req, name); if (action == "consoleIO") return consoleIO(req, webctl::str(a, "id")); if (action == "consoleClose") return consoleClose(req, webctl::str(a, "id")); return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404); } Loading
www/app.css +16 −0 Changes for www/app.css: 16 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -210,3 +210,19 @@ pre.log { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font /* folded parts of a dialog (containers: advanced settings) */ dialog details { margin: .4em 0 .8em; } dialog details > summary { cursor: pointer; color: var(--muted); margin-bottom: .5em; } /* console (terminal.js) */ .terminal { --term-bg: #15171a; --term-fg: #d6d8db; background: var(--term-bg); color: var(--term-fg); font: 14px/1.25 ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace; padding: 6px; border-radius: 6px; overflow: hidden; position: relative; outline: none; cursor: text; } .terminal:focus { box-shadow: 0 0 0 2px var(--accent, #3b82f6); } .terminal-screen > div { white-space: pre; height: 1.25em; } .terminal-measure { position: absolute; visibility: hidden; white-space: pre; } dialog.console { width: min(1100px, 96vw); max-width: 96vw; } dialog.console .terminal { height: min(70vh, 640px); } dialog.console .console-status { margin: .5em 0 0; color: var(--muted, #6b7280); font-size: .875rem; }
www/index.html +1 −0 Changes for www/index.html: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ <title>FenrirOS webctl</title> <link rel="icon" type="image/png" href="/favicon.png"> <link rel="stylesheet" href="/app.css"> <script src="/terminal.js" defer></script> <script src="/app.js" defer></script> </head> <body> Loading
www/modules/containers.js +81 −0 Changes for www/modules/containers.js: 81 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -8,6 +8,9 @@ 'Containers': 'Container', 'Images': 'Images', 'New container': 'Neuer Container', 'Pull image': 'Image laden', 'Name': 'Name', 'Image': 'Image', 'State': 'Status', 'Network': 'Netzwerk', 'Memory': 'Speicher', 'Start': 'Starten', 'Stop': 'Stoppen', 'Restart': 'Neu starten', 'Log': 'Protokoll', 'Edit': 'Bearbeiten', 'Console': 'Konsole', 'Console of {0}': 'Konsole von {0}', 'Close': 'Schließen', 'A shell in {0} as root. Ctrl+Shift+V pastes.': 'Eine Shell in {0} als root. Strg+Umschalt+V fügt ein.', 'The shell has ended.': 'Die Shell ist beendet.', 'The connection was lost: {0}': 'Die Verbindung ist abgebrochen: {0}', 'running': 'läuft', 'stopped': 'gestoppt', 'starting': 'startet', 'stopping': 'stoppt', 'exited': 'beendet', 'failed': 'fehlgeschlagen', 'exit code {0}': 'Exit-Code {0}', 'restarted {0}×': '{0}× neu gestartet', 'host': 'Host', 'none': 'keins', 'bridge': 'Bridge', Loading Loading @@ -170,6 +173,83 @@ }); } // A shell in a running container in the browser: terminal.js, the bytes // base64 over the module's consoleIO, polled (libhttppp has no WebSocket) function consoleDialog(name) { const toB64 = str => { let bin = ''; for (const b of new TextEncoder().encode(str)) bin += String.fromCharCode(b); return btoa(bin); }; const fromB64 = b64 => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); const screen = h('div', {}); const status = h('p', {class: 'console-status'}, t('A shell in {0} as root. Ctrl+Shift+V pastes.', name)); const dlg = h('dialog', {class: 'console'}, h('h2', {}, t('Console of {0}', name)), screen, status, h('div', {class: 'dialog-buttons'}, h('button', {type: 'button', onclick: () => finish()}, t('Close')))); document.body.append(dlg); dlg.showModal(); let id = null, pending = '', size = null, closed = false, timer = null, idle = 0; const term = new WebctlTerminal(screen, { onData: d => { pending += d; poll(true); }, onResize: (cols, rows) => { size = {cols, rows}; }, }); term.focus(); const finish = async () => { if (closed) return; closed = true; clearTimeout(timer); term.dispose(); dlg.close(); dlg.remove(); if (id) try { await call('containers', 'consoleClose', {id}); } catch (e) { /* already gone */ } }; dlg.addEventListener('cancel', ev => { ev.preventDefault(); }); // Escape belongs to the shell let busy = false, again = false; async function poll(now) { if (closed || !id) return; // before consoleOpen: input waits in pending if (busy) { again = true; return; } clearTimeout(timer); busy = true; try { const args = {id, input: toB64(pending)}; pending = ''; if (size) { Object.assign(args, size); size = null; } const r = await call('containers', 'consoleIO', args); if (r.output) { term.write(fromB64(r.output)); idle = 0; } else idle++; if (!r.alive) { status.textContent = t('The shell has ended.'); id = null; return; } } catch (e) { status.textContent = t('The connection was lost: {0}', e.message); id = null; return; } finally { busy = false; } // quick while something happens, slower when idle const delay = again || now ? 0 : idle < 10 ? 60 : idle < 50 ? 200 : 600; again = false; timer = setTimeout(() => poll(false), delay); } (async () => { try { const r = await call('containers', 'consoleOpen', {name, cols: term.cols, rows: term.rows}); id = r.id; poll(true); } catch (e) { status.textContent = e.message; } })(); } async function containerDialog(name) { const images = data.images || []; if (!name && !images.length) { Loading Loading @@ -337,6 +417,7 @@ up ? h('button', {class: 'small', onclick: () => act('stop', c.name, t('{0} is stopping …', c.name))}, t('Stop')) : h('button', {class: 'small primary', onclick: () => act('start', c.name, t('{0} is running.', c.name))}, t('Start')), up ? h('button', {class: 'small', onclick: () => act('restart', c.name)}, t('Restart')) : null, up ? h('button', {class: 'small', onclick: () => consoleDialog(c.name)}, t('Console')) : null, h('button', {class: 'small', onclick: () => logDialog(c.name)}, t('Log')), h('button', {class: 'small', onclick: () => containerDialog(c.name)}, t('Edit')), up ? null : h('button', {class: 'small danger', onclick: () => removeContainer(c)}, t('Delete')), Loading
www/terminal.js 0 → 100644 +399 −0 File added.Preview size limit exceeded, changes collapsed. Show changes